# 变更史 —— dsh-outcomes

> 按版本记录"改了什么、为什么、怎么验证的"。当前用法看 [README.md](README.md)。

## 0.1.2 — 2026-10-23

- security: `/__dsh-outcomes` 路由加 **loopback + Origin 守卫**；此前该前缀路由不走 Web UI 登录鉴权，
  `dsh web` 一旦绑到局域网/隧道，远端可读已登记文件。
- fix: 文件路由**先 open 成功再 writeHead**，并监听 ReadStream 的 `error`；避免 `statSync` 与
  `createReadStream` 之间文件消失时未捕获异常带走宿主进程。
- fix: 管理页里的 `registry` / `deletedFile` / `dir` 做 HTML 转义（路径含标签/引号时不再注入）。
- fix: 重写临时文件改为 `pid + 时间 + 随机` 唯一名，降低两个进程同时写同一个 `.tmp` 的碰撞。
- 验证：`node --test` → 99/99（新增 loopback/跨源/页面转义 3 条）；e2e 仍全过。

## 0.1.1 — 2026-10-22

- fix(pkg): `bin` 从 `./bin/dsh-outcomes.mjs` 规范为 `bin/dsh-outcomes.mjs`；npm 11 的
  “invalid bin” warning 是误报，0.1.0 的 tarball 实际带 `bin` 且可安装。
- 0.1.0 已在 npm live；0.1.1 是后续修复版，不是“首个可安装版本”。
- 验证：`npm pack --dry-run` 无 warning；`node --test` 96/96。

## 2026-10-22 · 显式 `dir` 不再被设置文件的 `registry` 拽回

- 现象：`config.dir`（或 `DSH_OUTCOMES_DIR`）单独给定时，`dir` 解析正确，但 `registry` / `deleted`
  仍取用户设置文件里的旧值 —— 测试用临时目录，清单却写进真实清单，
  `host.test.mjs` 因此在"list 应只有 1 条"上读到 67 条（`npm test` 长期红在这里）。
- 修法：`resolveOptions()` 里显式 `dir` 优先于设置文件的 `registry` / `deleted`；
  显式的 `config.registry` / `config.deleted` 仍最高优先。
- 验证：`test/paths.test.mjs` 新增 1 条（config.dir / env dir / 显式 registry 三种）；
  `node --test` → `tests 96 / pass 96 / fail 0`；`npm run preflight` 通过。
- CI：`test/paths.test.mjs` 的 `DSH_HOME` 用例不再写死 `C:\tmp\x`（POSIX 上会被当相对路径），
  Ubuntu 腿恢复全绿。

## 2026-10-21 · Node 24 下的 `npm test`

- 现象：`npm test` 用的是 `node --test test/`，在当前 Node 24 上直接 `Cannot find module '...\test'`（0 pass / 1 fail）。
- 修法：改成 `node --test`（自动发现 `test/**/*.test.mjs`；Node 20.11+ / 22 / 24 都支持，不依赖 shell 展开）。
- 验证：`npm test` → `tests 93 / pass 93 / fail 0`（exit 0）。


## ✅ 已修缺陷（第 14 轮实测发现，第 16 轮修复并双向验证）

**无 Web 服务的 profile 里插件不激活。** 隔离 headless 实例的原始输出：

```
dsh: warning: 1 entry did not activate
dsh-outcomes (dsh-outcomes): pending (waiting for service: webServer)
```

那个实例里**没有 `outcome_add` 工具**（让模型用它登记文件时，模型如实回答工具列表里没有它）。

**根因**：`lib/index.js` 声明 `export const inject = ['webServer']` —— cordis 语义是"**必需服务**未就绪则整个
插件保持 pending"，于是 `apply()` 根本没执行，**工具一个都没注册**。

**为什么是缺陷**：插件有**两半** —— 页面（需要 webServer）与 **agent 工具（不需要）**。
在 headless / TUI / 只用 agent 的场景里本该照样能登记成果，现在完全不可用。

**受影响**：任何没有 `webServer` 的 profile（headless、纯 agent profile）。
**不受影响**：含 `@deepseek-ai/dsh-web-app` 的 profile（日常那套）—— Web 形态已多轮验收通过。

**修法已实施（第 16 轮）**。下面是定形修法的探针实验与结论：

**探针定形**：做一个探针包（**不声明 `inject`**，apply 里记录两个服务是否就绪），
分别装进隔离的 headless 与 web profile 跑一遍。实测：

| profile | apply 执行了吗 | apply 时 `webServer` 就绪 | apply 时 `tools` 就绪 |
|---|---|---|---|
| headless | ✅ 执行了 | ❌ no | ❌ no |
| web | ✅ 执行了 | ❌ **no** | ❌ no |

**结论**：把 `inject` 直接删掉是错的 —— 插件会激活，但**在 web profile 里 apply 时 `webServer` 还没就绪**，
路由就永远挂不上，页面直接坏掉（比现在的缺陷更糟）。而 `tools` 服务在 apply 时**也没就绪**，
所以"工具那一半"必须自己等 `tools`。

**因此正确的修法是把两半分给两个 entry**（用的都是已实测可用的静态 `inject` 数组）：

| entry | 模块 | inject | headless | web |
|---|---|---|---|---|
| 工具半 | `lib/tools.js`（新） | `['tools']` | ✅ 激活 → **6 个工具可用** | ✅ 激活 |
| 页面半 | `lib/index.js` | `['webServer']` | pending（一行 warning，无害） | ✅ 激活 → 页面/路由可用 |

`cordis.patch.yml` 插两条 entry；包主入口改为工具半（`main: lib/tools.js`）。
这样"没有 Web UI 的 profile 里 agent 照样能登记成果"就成立了 —— 而这正是这个插件的主用途。

**实施后的验收（第 16 轮实测，两侧都跑了）**：

- **headless**：只剩页面半 pending（`dsh-outcomes-web … waiting for service: webServer`），
  **模型找到了并真的调用了 `outcome_add`** —— 隔离清单里出现 `20261016-030303-d61d  [文档] hl-check · acceptance-tools`；
- **web**：`tools/acceptance.mjs` **9 步全过、退出码 0**（bundle 组合 / 页面 200 / 登记 / 列表 / 文件服务 / 配置生效）；
- **回归测试**：`test/shipped.test.mjs` 新增一条钉死结构（工具半 `inject: ['tools']`、**代码里不出现 webServer**、
  页面半等 webServer、patch 两条 entry、`main` 是工具半）。

> 写那条回归测试时又错了一次：断言"`tools.js` 里不许出现 webServer"，而它**注释**里当然会提到（那正是这个文件的由来）——
> 改成剥掉注释只看代码才是有意义的判据。

### 第 4 轮补充（同日）：README 截图 + 两条实测教训

- **新增 `tools/screenshots.mjs`**：造一组演示数据（含一条"文件已不在"的记录）、把插件挂在临时端口、
  用 headless Chrome **真的打开页面拍两张**（中/英），写 `screenshots.json`，然后自己收尾。
  图随包发布（`files` 里加了 `assets/screenshots` 与 `screenshots.json`），README 中英各引用一张。
  为什么值得有个脚本：README 的图最容易过期，因为没人愿意重拍 —— 现在重拍是 10 秒的事。
  另加 `--serve <端口>`：只起演示服务不截图，想手工看一眼页面时用。
- 测试 76 → **77 条**：新增一条钉住"README 引用的图必须存在、`screenshots.json` 与磁盘一致、
  图不能小到像失败图"。

**这轮踩到并修掉的两个真问题**（都写进代码注释了）：

1. **`spawnSync` 把演示服务饿死**：截图脚本原先用同步等待 —— 而演示服务就跑在同一个 Node 进程的事件循环里，
   同步等待把事件循环整个卡住，Chrome 请求页面永远拿不到响应，脚本挂满 120 秒被 SIGTERM 杀掉。
   现象很像"Chrome 有问题"（stderr 全是它的 updater 报错），实际是自己把服务器饿死了。改用异步 `spawn`。
2. **截图会把本机用户名印在图上**：演示数据原来放系统临时目录，于是页头/路径列出现 `C:\Users\<用户名>\…`。
   改成放项目内的 `.demo-tmp-*`（跑完删除、也进了 `.gitignore`），发布出去的图上不再有用户名。

### 第 5 轮补充（同日）：把浏览器相关的两处收拢成一个工具，并给验收加"真浏览器"这一路

- **新增 `tools/chrome.mjs`**（截图与验收共用）：`findChrome()` / `runChrome()` / `dumpDom()`。
  两条实测教训写进了它的文件头：① 必须给 `--user-data-dir`（本机 Chrome 已在跑时会把请求交给已有实例然后自己退出）；
  ② **不能用 `spawnSync`**（调用方的事件循环里往往跑着本地服务，同步等待会把服务器饿死）。
- **装载验收新增第 10 步：用真浏览器读宿主自己的启动清单**。原来用 `fetch` 拿 `/` 会 401
  （宿主的浏览器信任围栏），只能降级成"人工看一眼"；现在改走 Chrome `--dump-dom`。
  实测**宿主 Web 端是 SPA、`--dump-dom` 常常等不到"页面完成"**，所以这一步给了短预算与 30 秒上限：
  读到了就断言 `modules[].id = dsh-outcomes`，读不到就**如实**降级成一条 10 秒的手工确认，
  并列出静态保证（`npm test` 已断言 client.js 的 `id` 等于包名、`inject` 与 package.json 一致）。
  宁可说"这次没判到"，也不谎报成功。

### 第 6 轮补充（同日）：补上"API 能改、工具不能改"的那一环 + 威胁模型文档

- **新增 agent 工具 `outcome_update`**：HTTP API 一直有 `/api/update`，但 agent 侧没有对应工具 ——
  于是模型把标题写错、备注写漏时**只能再登记一条**（重复登记比改一条糟得多）。
  现在 6 个工具：`add / list / update / check / remove / restore`。
  文档一致性测试当场抓到了这次改动（它按 index.js 里的工具名序列比对 README），
  这就是那条测试存在的意义 —— 工具名与说明书漂移是静默的。
- **新增 `SECURITY.md`（随包发布）**：威胁模型（在范围内 / 不在范围内各一栏）、
  **明说路由不做鉴权**（与 DSH UI 同等可达，暴露到回环之外请自己在反代上加认证）、
  能力边界指针指回 `docs/CAPABILITY-DISCLOSURE.md`、以及漏洞报告方式与支持版本。
  另在 README 的安全段加了指针。
- 复核：**77 条测试全绿**、`npm run check` 全过、**真机装载验收在改完工具后重跑仍全过**（退出码 0，隔离实例已收尾）。

### 第 7 轮补充（同日）：并发冲突从"静默覆盖"改成"明确拒绝"

- **乐观并发**：`update` / `remove` / `restore` 都是「读—改—写」，现在读的时候连**内容哈希**一起记下
  （`readJsonl()` 返回 `snapshot`），提交前复核哈希有没有变；变了就**不动它**，返回
  `{ ok:false, error:'concurrent-modification' }`，并删掉 `.tmp`。原来两个进程同时写会**静默丢一条登记**，
  现在会得到一个明确的失败 —— 这是"丢数据"和"报错"之间的区别。
  没有用文件锁：锁会带来挂住与死锁的风险，而复核 + 拒绝在任何时候都不会卡住调用方。
- `remove` **调换了顺序**（先重写、再留档）：反过来的话，一旦并发冲突，删除留档里已经多了一条
  指向"其实还在用"的条目的记录，反而更难收拾。两次之间崩掉时，下一次重写的自愈会记成 `auto-archived`。
- `restore` 增加了对删除留档的**提交前复核**，并在极小的窗口内冲突时如实返回
  `note: 'entry-restored-deleted-log-unchanged'`（不报成功、也不报"什么都没发生"）。
- 测试 77 → **79 条**：两条并发用例（一条验证拒绝时清单/留档/`.tmp` 三者都不动、且另一进程那笔还在；
  一条验证没有冲突时正常路径不受影响）。`options.beforeCommit` 是给并发用例的测试缝，已在代码里注明。
- 复核：79 条全绿、`npm run check` 全过、**真机装载验收重跑仍全过**（退出码 0，隔离实例已收尾）。
  README 中英的「已知限制」同步改写成新行为。

> 写并发用例时自己踩了一次：注入钩子被触发了两次，断言却按"只注入一次"写 —— 是**测试**错了而不是代码错了。
> 教训：并发用例要断言"**正好**被改成什么样"（记下期望内容再比对），而不是"没变过"。

### 第 8 轮补充（同日）：验收对象换成"会发布出去的那个东西"（tgz）

- **`tools/acceptance.mjs --from-pack`**：先 `npm pack`，再把**产出的 `.tgz`** 装进隔离 profile 跑完整条验收。
  实测：`dsh-outcomes-0.1.0.tgz`（186 KB）→ 装进全新 profile → bundle 组合进配置树 → 起服务 →
  管理页 200 / 登记 / 列表 / 文件服务（内联 + 沙箱 CSP）→ 收尾。**9 步全过、退出码 0。**
- 为什么必须单独验这个：**工作树能跑 ≠ 发布出去的那个能跑**。`files` 白名单漏一个文件
  （比如 `lib/page.js`），本地怎么跑都是好的，只有装上 tgz 才会暴露 —— 而那正是"发版之后才发现、
  最贵"的那一类错。这条命令把这类错提前到了打包之后。
- 顺带记一笔：`npm pack --json` 的输出是解析产物的稳妥入口（拿 `filename` 而不是自己拼包名版本号）。

### 第 9 轮补充（同日）：补上"渲染出来的页面"这一层的测试

管理页是**请求期渲染**的产物：它不是一个能 `node --check` 的文件，而是模板拼出来的 HTML + 内联脚本。
所以"页面坏了"这件事，之前**任何检查都抓不到**。新增 `test/page.test.mjs`（6 条）盯住三类静默失败：

1. **占位符没被替换**（token 名写错）→ 浏览器里 `var PAGE_SIZE = __PAGESIZE__;` 直接抛错、页面空白，
   而 Node 侧一切正常。现在断言渲染结果里不留 `__XXX__`。
2. **内联脚本不是合法 JS**（模板被别的内容截断过——第 3 轮真犯过）→ 现在把 `<script>` 内容抠出来
   落成临时文件跑 `node --check`，并断言 `pageSize` / 守卫头 / 路由前缀**真的进了脚本**
   （否则它们只是 config 里的装饰）。
3. **中英词典 key 不一致** → `t('x')` 会把 key 原样吐出来，界面上出现 `count.window` 这种字样。
   现在断言两侧 key 集合完全一致、且每条都非空。

顺带钉住：页面自包含（不引外链、不出现外部 URL）、页头显示清单位置与删除留档位置。

**两条断言一开始是错的，值得记下来**（我按"应该是什么样"写，实测按"实际是什么样"改）：
① 我以为页面**没有**内联事件属性 —— 实际语言切换就是一个内联 `onclick`（页面本就有内联脚本块，
CSP 因此开着 `'unsafe-inline'`）。断言改成"**只许有这一个**"，并把它的行为钉住（改本站 URL 的 `lang`，不跳外部）；
② 我以为语言切换是 HTML 里的链接 —— 实际是按钮 + 脚本改 URL。按实际情况重写。

> 这两条正好说明这类测试的价值：**它逼着你确认实现到底是什么样**，而不是让"我以为"留在文档里。

测试 79 → **85 条**。

### 第 10 轮补充（同日）：把散落的设计取舍收进一份 `docs/DESIGN.md`

前面 9 轮的取舍散在 CHANGELOG 各轮里 —— 对维护者和评审者来说，"为什么这么写"比"改了什么"更难找。
新增 [`docs/DESIGN.md`](docs/DESIGN.md)，按**决策**归拢 12 条，每条都写 **选了什么 / 为什么 / 代价是什么**
（代价那条最重要：没有代价的方案通常意味着还没想清楚），外加一份**已知代价汇总表**与测试策略说明。

写的时候顺手核对并写清了几处此前只在代码注释里的理由：
- 为什么 `remove` 的顺序是"先重写、再留档"（反过来一旦并发冲突，留档里会多一条指向仍在用条目的记录）；
- 为什么并发不用锁（锁要处理持有时长/崩溃残留/超时，任何一处没处理对就会**挂住宿主进程**——
  对插件来说这比报错严重得多）；
- 为什么 SVG 一律当附件、为什么 disposition 按**扩展名**而不是条目类型判断（曾因此放过一个真 bug）；
- 为什么路由不自己做鉴权（半套鉴权只会制造虚假的安全感，边界应由宿主/反代决定）。

README 中英各加了指针（否则这份文档等于不存在）。复核：**85 条测试全绿**、
`npm run check` 全过（随包文件 21 个、泄漏扫描干净）。

### 第 12 轮补充（同日）：验收补上"配置在真宿主里真的生效" + `CONTRIBUTING.md`

- **装载验收新增一条断言：用户设置文件里的 `dir` 必须生效**。做法是在起隔离实例**之前**往
  `$DSH_HOME/.dsh-outcomes.json` 写一个自定义目录，然后断言 `/api/status` 报出的 `dir` **正好**是它、
  且 `sources.dir === 'settings-file'`。实测通过：
  `…\configured-outcomes ← settings-file`。
  为什么值得单加：默认目录那条路径单元测试早就覆盖了，而**配置不生效的症状是"清单悄悄写到别处"**——
  用户在界面上看到的是"我登记的东西不见了"。这种错只有真起一次宿主才敢说没问题。
- **新增 `CONTRIBUTING.md`（随包发布）**：开发方式（零依赖、`npm test` / `preflight`）、
  提交前的五条（含"改了页面要重跑截图脚本"、"改了行为要同步 DESIGN 的代价栏"）、
  这套测试**防的是漂移而不是覆盖率**（附文件对照表）、不要提交什么、文档语言约定、以及
  安全条款里的那句"**请不要顺手加半套鉴权**"。

测试仍是 **85 条**（本轮加的是验收脚本里的断言，不是单测）；`npm run check` 全过；包 28 文件 / 317.5 KB。

### 第 19 轮补充（同日）：把「agent 工具能用」这条主用途自动化进验收

- `tools/acceptance.mjs` 新增 `--tools-check`：再起一个 **headless** profile（无 Web 服务），让**真模型**
  去调 agent 工具登记一个文件，然后断言隔离清单里真的多了一条。实测通过：
  `headless (no web service): a real model registered a deliverable via the agent tool -- registry gained tools-check`。
- **为什么值得自动化**：这是插件的**主用途**，也正是第 14 轮整个坏掉的那条路径。以前它是「手工跑一次、
  记进 CHANGELOG」的一次性动作，下次改动没人会再跑；现在是一条命令，而且**明确会失败** ——
  脚本里有一条断言专门盯第 14 轮的缺陷形态（headless 里若出现 `dsh-outcomes (dsh-outcomes): pending
  (waiting for service...)` 就直接判失败），而不是「看着不对就算了」。
- **需要凭据**：只在显式给 `--tools-check` 且 `~/.dsh/.credentials.yaml` 存在时才跑；拿不到就**如实标
  「已跳过」**。凭据只被**复制**进隔离 home，不动原件。
- 顺带确认：headless 形态下同一份用户设置文件（`dir`）同样生效 —— 配置解析与 profile 形态无关。
### 第 20 轮补充（同日）：把**客户端半部**真的跑起来测（以前只看字符串）

客户端半部（`lib/client.js`）此前**从未在任何测试里执行过** —— 只有静态断言（id 等于包名、inject 是数组）。
可它一旦在 `apply()` 里抛错、或注册描述符不合宿主约定，表现是**设置里根本没有这一节**：
不报错、不写日志、刷新也没用。这与"id 写错"是同一种静默失败，但覆盖面更大。

新增 `test/client-render.test.mjs`（6 条）：桩掉宿主的模块装载器与 `react`（本包零依赖，测试也不装），
然后 ① 跑 `apply()` 检查它**对宿主做了什么**；② 渲染组件检查它**画出了什么**：

- `apply()` 注册了词典（中英两份）、等到了 `slots`、注册了 `settings.section`（id `outcomes`、order 95），
  且 `label` / `inject` 都是**函数**（宿主调用时取值 —— 写成值就是静默不显示）；
- 中英词典 **key 一一对应**且都非空（缺 key 会在界面上显示成 `nav.label` 这种字样）；
- 组件在**四种状态**下都能渲染：加载态、有数据（条目链接指向宿主文件路由 `/__dsh-outcomes/file/<id>/0`、
  缺文件的带 ⚠、统计数字取自服务端 stats、显示数据目录）、错误态（区分"宿主没这个路由"与一般失败）、
  以及体检结果；
- **客户端常量与宿主半部交叉核对**：`PREFIX` 必须等于宿主 `ROUTE_PREFIX`、`GUARD_HEADER` 必须等于宿主同名常量
  —— 这类漂移同样是静默的（页面能开、按钮 403）。

测试 87 → **93 条**。
### 第 21 轮补充（同日）：两条真机行为实测（审批闸门 + CLI --json 契约）

**1. 非交互（headless）里 `outcome_remove` 会"失败关闭"，而且理由清楚。** 实测：

```
Error: tool "outcome_remove" requires approval, but no approval channel is available
```

要点：① **不是挂住、也不是静默成功**，而是明确报错（fail closed）——这是对的；
② 闸门在**查 id 之前**就拦下（我那次甚至喂了个不存在的 id，照样先被闸门拦住），对安全闸门来说顺序正确；
③ 被拒后**清单一个字都没动**（登记仍在那）。`outcome_add` / `list` / `update` / `check` / `restore` 不受影响。

**2. CLI 的 `--json` 对脚本友好。** 实测：stdout 是**纯 JSON**（stderr 为空）、可直接 `JSON.parse`；
`add` 返回 `{ok, entry, warnings}`，`list` 返回 `{rows}`。写脚本的人就是靠这个契约。

### 第 23 轮补充（同日）：`docs/TROUBLESHOOTING.md` —— 按**症状**查的排查手册

这个插件的失败方式**大多是静默的**（不报错，只是"东西不见了"）。零散的原因此前只在 CHANGELOG 各轮、
DESIGN 的取舍表、以及我的测试记录里 —— 用户遇到"设置里没有这一节"时，没有一份能按症状查的东西。

新增排查手册，11 条，全部来自实测而非推测：设置里没有 Outcomes（客户端 id 与包名不一致，**静默**）·
页面 404（没进 bundles / 装进了无 Web 服务的 profile / 没重启）· agent 说没有 `outcome_add`
（只有页面半被装上；**含"自己手写过 patch 的人升级后要补第二条 entry"**）· 清单写到别处
（看 `/api/status` 的 `sources.dir`，它如实报告哪一层生效）· 自动化里取消失败（审批闸门失败关闭，故意）·
`auto-archived` 是怎么来的 · `concurrent-modification` 怎么办 · SVG 为什么是下载 · 脚本 fetch 宿主 `/` 为什么 401 ·
写请求 403（缺守卫头，**不是鉴权**）· 以及"还是不对"时按顺序跑什么、报告问题要附什么。

README 中英都加了指针（否则这份文档等于不存在）。复核：93 条测试、`npm run check`（含链接完整性：
新文档里 `../SECURITY.md` 这类相对链接必须落在包内）全过；包 30 文件 / 337.7 KB。
### 第 24 轮补充（同日）：跨平台体检查出**一处真泄漏 + 扫描器自身的盲区**

起因：这个包只在 Windows 上跑过，而社区用户多在 macOS/Linux —— 于是按"有没有 Windows 专有假设"过了一遍随包代码。
**结果查出的不是平台问题，而是一处泄漏**：

1. **随包的 `tools/acceptance.mjs` 里写死了 `C:\Users\<本机用户名>\...`（DSH 的默认安装路径）**。
   社区包里带着作者的用户名，是实打实的泄漏。已改成**从环境变量推导**（`ProgramFiles` / `LOCALAPPDATA` /
   POSIX 的 `/usr/local/bin`），运行期解析、源码里不含任何用户名；本机仍能正确发现 CLI（已重跑验收确认）。
2. **更值得记的是：泄漏扫描器看不见它**。规则写的是 `[\\/]Users[\\/]<名>` —— 只吃**一个**分隔符，
   而 JS 源码里的路径是**转义**的（`\\Users\\<名>`）：两个分隔符 → 规则**永远匹配不上**。
   规则修成"容忍 1~2 个分隔符"，并**新增一条通用规则**：任何 `盘符:\Users\<真正的用户名>` 都拦，
   不管用户名是什么（不再只盯本机那一个）。

**防止"改了规则但规则其实是坏的"**：新规则做了**双向自证** ——
真用户名（`C:\Users\<用户名>\...`、转义的 `C:\\Users\\<用户名>\\x`）**必须命中**；
占位写法（`C:\Users\<用户名>`、`C:\Users\%USERNAME%\x`）**必须放过**（否则文档里的示例会被误伤 ——
收紧后确实有两条这类误报，就是这么发现的）。随包文件 23 个，扫描干净；93 条测试全绿。

> 教训：**"扫过了，没发现" 不等于 "没有"**。规则本身有盲区时，扫描器会一直报"干净" ——
> 所以每次给规则加分支，都要拿"该命中的"和"该放过的"各喂一遍。
### 第 25 轮补充（同日）：把泄漏规则做成**一份**并让规则自证（补掉第二处盲区）

第 24 轮查出"随包文件里有真用户名、而扫描器看不见"。这一轮追问了一句：**测试里那份规则呢？**
—— 答案是**同一个盲区**：`test/shipped.test.mjs` 里也写着只吃一个分隔符的弱规则，
所以 `npm test` 同样漏掉了那处泄漏。**规则分家 = 修一处、漏一处。**

- 新增 `tools/forbidden.mjs`：规则表**只此一份**，`tools/check.mjs` 与 `test/shipped.test.mjs` 共用；
  每条规则带**自证样本**（必须命中的 / 必须放过的），`selfTest()` 当场跑。
- 两份扫描器都补上通用规则（任何 `盘符:\Users\<真用户名>` 都拦）并改为调用共用实现。
- **规则表自己被排除在扫描外**（`check.mjs` / `forbidden.mjs`）：它当然包含那些禁忌串 ——
  这不是掩盖，是规则的定义处；这一点在代码里写明了理由。
- 自证样本里的本机用户名改成**运行时拼接**：自证要有真实用户名才有意义，但文件文本里不该出现它。

顺带被自己的新规则抓了两处**我自己写的**东西（都改了）：CHANGELOG 里拿真实用户名当举例；
以及规则表里直接写了样本。这正是收紧规则的价值 —— 它连作者的文档一起管。

**诚实的记账**：这一轮我先写了个"把两份规则合并"的脚本，它**语法错**（模板字符串里嵌了反引号）
导致整脚本没跑；随后两次小修又分别忘了补 `basename` 导入、忘了排除规则表自身。
三处都是当场被 `node --check` / 测试抓出来的 —— 没有一处是我"看出来"的。
### 第 26 轮补充（同日）：`npm run guards` —— 证明"守卫不是摆设"

第 24 轮的教训是"扫描器报干净，但规则本身有盲区"（那处真泄漏就躺在随包里）。
顺着这个思路要问的是：**其余守卫会不会也是摆设？** 于是写了 `tools/guard-check.mjs`：
在**临时副本**上把包**故意改坏**，看守卫会不会叫。

7 个变体，**全部被 `npm test` 与 `npm run check` 抓住**：

| 变体（故意引入的错误） | 期望被谁抓 |
|---|---|
| 随包文件里写入真实用户名路径 | 泄漏扫描 |
| 客户端 half 的 `id` 与包名不一致（宿主会静默不加载） | client 测试 |
| 工具改名但文档没跟 | docs.test（工具名序列） |
| 宿主路由前缀改了、客户端没跟（按钮会 403） | client-render（常量交叉核对） |
| `files` 白名单里写了一个不存在的文件 | shipped.test |
| 工具 half 又被改回依赖 `webServer`（**第 14 轮的缺陷形态**） | shipped.test（两半 inject 结构） |
| README 里的截图被删掉 | shipped.test |

脚本只动副本、跑完删掉，退出码即结论；已加进 `npm run guards`，并写进 CONTRIBUTING 的提交前清单
（"**动过任何守卫或扫描规则**就要跑它"）。

> 这条把第 14 轮那个真缺陷也变成了**可复现的守卫体检项**：谁把它改回去，体检会当场叫。

### 第 27 轮补充（同日）：验收补一条"web 形态下两半都不 pending"，并记下一个 DSH 事实

想法很直接：**默认形态（web profile）里的 agent 工具从没被验过** —— 路由验过、工具只在 headless 验过。
于是给 `--tools-check` 加了一段"在 web profile 上再跑一次同样的任务"。**结果它失败了**，而原因不在本包：

```
dsh --profile <web 形态> "<任务>"   →  exit 1
```

**web app 不接受位置参数**（第 2 轮我就撞过一次：`dsh --profile acc web …` 报 "too many arguments"）。
也就是说：**web 形态的 profile 里无法用 CLI 跑一次性 agent 任务** —— 那半的工具只会在**真实会话**里被用到。
这是 DSH 的事实，不是本包的缺陷；我上一轮那句"工具在 web 里也能用"本来就无从用 CLI 验证。

所以改成验一个**当时真能观测**的事实：web 形态下**两半都不 pending**
（pending 会打印 "N entry did not activate"；路由可用已在第 5 步证明）。实测通过：

```
✓ web-shaped profile: neither half is pending (routes proven in step 5; one-shot tasks cannot run here by design)
```

> 记账：这一条我先写错了前提（以为 CLI 能在 web profile 上跑任务），是**跑起来才发现的**。
> 把"验不了"如实写成"验不了 + 为什么"，比留一个看起来能跑、实际从没跑过的检查有用。
### 第 28 轮补充（同日）：把「真浏览器验客户端半部」这条**彻底问死**，以及一次 shell 事故的记账

**1. 结论：Chrome 的命令行截图/取 DOM 这条路，在 DSH 的 SPA 上走不通（已两次确认，可以不再试）。**
本轮把两种模式都试了：`--headless=old --timeout=6000` 与 `--headless=new --virtual-time-budget=4000`。
不带 token 时两者都只返回 222 字节（宿主的信任围栏页）；**带上 token 后 `--dump-dom` 直接不返回**
（挂到外层 10 分钟超时）。也就是说：**要自动判定「设置里那一节真的渲染出来了」，只能走 CDP 脚本化点击**，
而那是另一个量级的工程（且宿主 UI 随时会变）。所以这一步**保持人工 10 秒确认**，并且验收脚本每次都会把
具体地址与要搜的字符串打印出来 —— 这是诚实的上限，不是我偷懒。

**2. shell 事故记账（两个教训，都已写进用户级约定要防的那一类）**：
- **按"命令行包含某串"杀进程，会杀掉正在跑这条命令的 shell 自己** —— 我的过滤器匹配的是 `accd`，
  而这条命令的命令行里就有 `accd`。表现是"runner 报 managed range empty"，像是工具坏了。
  正确做法是先按**进程名**过滤再看命令行。
- **杀"属于 job 的进程"会把 runner 一起带走**：本机 pwsh 每次执行都在自己的 job 里，
  我用 `Stop-Process` 干掉由它启动的子进程后，runner 自身报错退出。改用 `taskkill /F /PID` 单独一条命令才干净。

**3. 收尾现状**（都在隔离环境里，与你无关）：19521 端口已释放、临时 `DSH_HOME` 已删、
你的 DSH（19387）与控制台（8790）全程正常；实验留下 1 个不再监听任何端口的孤立进程（隔离 home 已被删），
杀它会连带弄坏当前 shell 的执行器，故留着 —— 它不占端口、不碰任何真实数据。
### 第 29 轮补充（同日）：收尾一件小事，并记一个**新的 PowerShell 坑**

**1. 第 28 轮那个孤立进程：结论是"杀不掉，且无害，留着"。** 试了三种办法：
① 用 WMI 以"脱离调用者 job"的方式启动清理脚本（本机控制台服务用的就是这招）—— 进程创建成功（返回码 0），
但脚本**根本没跑起来**（原因见第 2 条）；
② 用 `powershell.exe` 前台跑 —— **解析失败**；
③ 用 `pwsh.exe` 跑 —— 脚本执行到 `Stop-Process` 时**把当前 shell 的执行器一起带走了**
（日志停在"找到 1 个目标"）。说明那个进程与执行器同属一个 job，杀它 = 连带拆掉执行器。

它不占任何端口、只属于已删除的隔离 home，所以**留着**是更划算的选择 —— 记账比硬杀干净。

**2. 新坑：`powershell.exe`（Windows PowerShell 5.1）读不了**没有 BOM 的 UTF-8 脚本里的中文**。**
同一个 `.ps1`，`powershell.exe` 报的是莫名其妙的语法错（中文变乱码、引号被截断），`pwsh.exe` 一切正常。
本机约定里原本只写了"**`.cmd`/`.bat` 必须纯 ASCII**"，这条应当**推广到 5.1 下的 `.ps1`**：
要么用 `pwsh` 跑，要么脚本内容保持 ASCII。

## 0.1.0 — 2026-10-16
首个版本。它不是从零写的：清单模型来自作者自用控制台里跑了一个多月的成果登记（`outcome.mjs`，
含删除留档、原子重写、自愈），这一版按"能给社区用"重做。下面把**取舍**写清楚，因为它们解释了这个包为什么长这样。

### 加进来的

- **清单核心**（`lib/store.js`，零依赖）：只追加的 JSONL 清单；删除进 `deleted.jsonl`（带 `removedAt`/`why`）并可放回；
  重写走 `.bak` → `.tmp` → `rename` 原子替换；重写时**自愈**（消失且没留档的条目自动补 `why=auto-archived`）。
- **agent 工具**（`lib/tools.js`，`inject: ['tools']`）：`outcome_add` / `outcome_list` / `outcome_check` / `outcome_remove` / `outcome_restore`。
  `outcome_remove` 走 `tools/pre-execute` 闸门，改清单前问用户一句。
- **管理页**（`lib/page.js`）：**请求期渲染**的自包含页面（无构建步骤、无外部资源、无 CDN），
  中英双语（`?lang=zh`）、搜索/类型/状态筛选、按类型预览、取消登记与放回。
- **客户端半部**（`lib/client.js`）：在 **设置 → Outcomes** 注册一个分节，显示规模、最近几条、体检按钮，
  以及"打开管理页"入口。手写、无需构建（形态照宿主 `__ModuleLoader__` 约定）。
- **CLI**（`bin/dsh-outcomes.mjs`）：`add/list/check/remove/restore/deleted/stats/dir`，`--json` 机读；
  **不需要 DSH 在跑**，与插件共用同一份清单。
- **配置**（`lib/paths.js`）：`config → 环境变量 → 用户设置文件 → 默认`，数据默认落在
  `$DSH_HOME/plugin-data/dsh-outcomes`。**代码里没有任何个人绝对路径**（`test/shipped.test.mjs` 会扫）。
- **测试**：37 条 `node:test` 用例 + 包卫生与文档一致性断言；`tools/check.mjs` 做语法、CLI 冒烟与泄漏扫描。

### 与个人控制台版的三处**有意不同**

| 个人版 | 这一版 | 为什么 |
|---|---|---|
| 清单路径写死在作者本机的一个固定目录 | 全部可配，默认走 DSH 的 `plugin-data` 约定 | 发布给别人用，代码里不能有我的路径 |
| **「页面是生成物」**：登记后必须重跑 `build-page.mjs` 才可见 | **请求期渲染**：刷新即见 | 生成物模型带来注入点、脆弱的 HTML 断言与"忘了重建"这一整类坑；清单是本地小文件，现算的代价可忽略 |
| 带「删除会话」（直连宿主 `dsh-session-manager` 的 HTTP API） | **去掉**，只保留"关联会话 id"的只读展示 | 删会话是宿主/会话管理插件的职责；跨机器它必然降级报错，不该塞进成果工具 |

另有两处收紧：**写操作要求守卫头**（原来也要求，但头名与个人控制台主站耦合，现在独立成 `x-dsh-outcomes`），
以及**被登记的 HTML 以 `CSP: sandbox` 送出**（个人版是直接内联服务，等于让被登记的文件拿到宿主同源身份）。

### 怎么验证的

- `npm test`：store 19 条 + host 18 条 + client 6 条 + 包卫生 9 条 + 文档一致性 8 条，全绿。
- 宿主半部用**桩 ctx**（webServer / tools / logger）驱动真实路由处理器：含守卫头 403、非法 JSON 400、
  越界/未知 id/穿越 404、remove↔restore 往返 200/409、文件路由的 `inline` 与沙箱 CSP 头。
- CLI 在临时数据目录上跑通 `add → list → check → remove → restore`（`npm run check` 会冒烟一遍）。

### 第 3 轮补充（同日）：把"文档承诺了但代码没做"的那一项补上

- **`pageSize` 原来只在 `lib/paths.js` 里被解析、没有任何地方用它**，而 README 写着"管理页一次最多列多少行"。
  现在它是**服务端默认窗口**：`/api/list` 不传 `limit` 时给这么多，显式 `limit` 也尊重（硬上限 10000），
  响应回 `window: { limit, pageSize }` 让调用方看得见。
- 顺带修掉一个**很难被发现的错**：管理页原来固定拉 1000 条在**本地**过滤 —— 清单超过窗口后，
  搜索会"搜不到明明存在的条目"。现在搜索框带 `q` **重新查服务端**（250ms 防抖），窗口满时页面会给提示。
- 新增 `test/paths.test.mjs`（9 条）：config / 环境变量 / 设置文件 / 默认**四层全摆上**验证优先级、
  非数值回落默认（不产生 NaN）、坏 JSON 不抛错、`~` 展开、相对路径按 cwd 解析。
  这类错误不报错，只会"清单悄悄写到别处"，必须靠断言钉住。
- 测试：66 → **76 条**；真机验收在改完 `page/list` 之后**重跑一次仍全过**（退出码 0）。

> 教训：`页面固定拉 N 条 + 本地过滤` 与 `pageSize` 这类配置项**必须一起测** ——
> 前者让配置变成装饰，后者的存在感又掩盖了前者的错。现在两条都有断言。

### 第 2 轮补充（同日）：真机验收自动化 + 迁移工具

| 加的东西 | 解决什么问题 |
|---|---|
| `tools/import-registry.mjs` | 把**旧格式**清单迁进新清单：id 原样保留（断链最贵）、缺文件的条目照常导入、目标非空时拒绝（`--force` 只做按 id 去重追加）、幂等、`--dry-run` 可先看要导什么 |
| `tools/acceptance.mjs` | 把"真机装载验收"从**手工清单**变成**一条命令**：临时 `DSH_HOME` + 另一个端口起隔离实例，9 步全过后自己收尾（**失败也收尾**） |
| `store.appendRaw()` | 迁移专用入口：绕开 id 生成与"文件必须存在"的检查 —— 历史记录的价值是"当年登记过" |

**真机装载验收（隔离实例，没碰日常在用的 DSH）：9 步全过、退出码 0。**
`临时 DSH_HOME 建 profile → 官方 dsh plugin add file:… → --dump-config 断言 bundle 进了 profile 树 →
起 Web 服务 → 管理页 200 → 登记 → 列表 → 文件服务（内联 + 沙箱 CSP）→ 关实例`。
**客户端半部**在真机上手工确认过：宿主启动清单里出现
`{"id":"dsh-outcomes","url":"plugins/??dsh-outcomes/client.js&rev=…","inject":["@deepseek-ai/dsh-client-locale","@deepseek-ai/dsh-client-ui-settings"]}`
—— `resolveMeta()` 认得这包、`id` 与 `inject` 都对。脚本里这步遇到 `GET /` 401（宿主的浏览器信任围栏）会降级成
"需要人工看一眼"，**不谎报成功也不误报失败**。

**迁移脚本在真实数据上跑过（用副本，没动原清单）**：个人控制台那份清单
**115 条在用 + 20 条删除留档 → 全部导入、0 个文件缺失、0 行解析失败**；再跑一次 115 条全跳过（幂等）。

**两个被实测纠正的设计**（写下来免得下次再犯）：
① 迁移脚本原来在"目标非空"时一律拒绝，导致**幂等没法用**（第二次跑被自己挡住）—— 改成先算清"要写什么"，
全是已存在的 id 时按无害空跑处理；
② 验收脚本原来在失败路径上**跳过收尾**，把隔离实例留在后台占着端口（真发生过一次）——
改成 `report()` 无论成败都先关实例。

### 第 30 轮补充（2026-10-18）：同一类缺陷的**第二个实例** —— `allowOutsideCwd` 也只是个摆设

- **缺陷**：`allowOutsideCwd` 在 `lib/paths.js` 里被解析、在 README 里被承诺（"是否允许登记会话 cwd
  以外的路径"），但**全仓没有消费者**。设成 `false` 不会发生任何事，登记照收 —— 与第 3 轮修掉的
  `pageSize` 是**一模一样的形态**（解析出来、文档写了、没人用），只是当时没一起查。
  它甚至被 `test/docs.test.mjs` "保护"着：那条测试只断言 README 写了这个键，
  于是**测试在维护一个不存在功能的文档**，反而让缺陷更难被发现。
- **修法**：`lib/store.js` 里新增 `outsideOf(paths, cwd)`（走 `path.relative`，跨盘符与 `../` 逃逸都算"在外面"），
  在 `add()` 与 `update()` 换路径时各拦一次，返回 `{ok:false, error:'outside-cwd', outside:[…]}`；
  默认仍是 `true`，因此**不改变现有行为**，只有显式 `false` 才生效。
- **验证**：`test/store.test.mjs` 新增 2 条 —— 默认 `true` 时 cwd 外的路径照收；`false` 时
  `add`（绝对路径与 `../` 逃逸两条路）与 `update` 都被拒、且清单不落盘（`readRegistry` 仍是 1 条）。
  测试总数 93 → **95**，全绿。
- **顺带修**：`package.json` 的 `"test": "node --test test/"` 在 Node **v24.21.0** 上直接报
  `Cannot find module …\test`（目录被当模块加载）；改为 `node --test`（默认发现）后 95 条正常跑。
  也就是说 `npm test` 与 `preflight` 在此之前是坏的，而代码本身没问题 —— 坏在入口。

> 教训（第 3 轮那条的加强版）：**"配置项存在"与"配置项生效"是两件事**，而前者会让后者看起来已完成。
> 同一份 `paths.js` 里能被解析却无人消费的键，**必须逐个找到消费点**；找不到的要么接上，要么删掉。

### 第 31 轮补充（2026-10-18）：守卫体检补上「基线」前置断言

- **隐患**：`tools/guard-check.mjs` 只判"变体跑完，测试/检查是否非 0"，**从不验证未改动的副本自己是绿的**。
  于是当测试入口坏掉时（正是第 30 轮修的那件事：`node --test test/` 在 Node 24 上报
  `MODULE_NOT_FOUND`），**7 个变体会全部"被抓住"** —— 脚本报"这些守卫不是摆设"，而实际上
  它测到的只是"入口是坏的"。这类假绿比漏网更危险：漏网会报红，假绿会报绿。
- **修法**：变异循环之前先对**未改动的副本**跑一遍 `node --test` 与 `tools/check.mjs`；
  任一非 0 就打印两边退出码、清理临时目录、`exit 2`，并明说"先把基线修绿再跑；否则结论无效"。
  退出码刻意与"有变体漏网"（1）区分开 —— **基线坏**与**守卫弱**是两种病，处置不同。
- **验证**（两条路都跑）：
  ① 正常路径：基线绿 → 7 个变体仍全部被抓住，`exit 0`；
  ② 反例：在临时副本里改坏一条测试断言（让基线必红）再跑 → 脚本报"基线不绿，结论无效"、
  打印 `node --test exit 1`、`exit 2`，**不再输出"全部被抓住"**。
- 耗时代价：多跑一遍全量测试与检查（本机约 +7 s），换来"绿是真的绿"。
