dsh-fact-check 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ADAPTING.md CHANGED
@@ -1,9 +1,14 @@
1
1
  # 换环境适配清单(ADAPTING)
2
2
 
3
- 本技能**不绑定任何主机、产品、路径或密钥**:正文只讲能力与规则,两个工具是零依赖的 Node 脚本,所有环境相关的值都在 `tools/local.json`。本文说明落到你的环境需要做哪几件事。
3
+ 本技能**不绑定任何主机、产品、路径或密钥**:正文只讲能力与规则,两个工具是零依赖的 Node 脚本,所有环境相关的值都在 `local.json`(放在**包外**的用户配置目录,见下)。本文说明落到你的环境需要做哪几件事。
4
4
 
5
5
  > 逐步执行请用 **`FOR-AI.md`**(它是操作手册);本文是配套的背景与决策依据(为什么这样改、没有决策模型怎么办、判据校准纪律)。
6
6
 
7
+ > **配置文件位置**:默认 `%LOCALAPPDATA%\dsh-fact-check\local.json`(Windows)或
8
+ > `~/.config/dsh-fact-check/local.json`;`JEV_LOCAL_CONFIG` 可指定任意路径;旧的包内
9
+ > `tools/local.json` 仍会被读,但只作为兜底。放在包外是为了 `dsh plugin update` / `npm i`
10
+ > 换掉整个包目录时,不会把本机设置一起清掉。`secrets.json` 用同一套目录。
11
+
7
12
  ---
8
13
 
9
14
  ## 1. 五分钟接上
@@ -17,13 +22,13 @@ node tools/doctor.mjs --net # 建议也跑一次:会真跑
17
22
  export TYPESAFE_API_KEY=… # 或 doctor --write 生成 local.json 后填 keyFile
18
23
 
19
24
  # 3) 本机值(可选):自托管搜索、代理、可选工具路径;还有能力绑定 / 输出约定 / 运行时基线
20
- node tools/doctor.mjs --write # 生成 local.json 草稿,再把 null 的项补上
21
- # 或: cp tools/local.example.json tools/local.json
25
+ node tools/doctor.mjs --write # 生成配置草稿(写到包外用户目录),再把 null 的项补上
26
+ # 或: 把 tools/local.example.json 复制成配置文件(路径看 doctor 输出里的「本机配置」一行)
22
27
 
23
28
  # 4) 验收(离线,不花钱)
24
29
  node tools/jev-verdict.mjs selftest # 期望 25/25
25
- node tools/route.mjs selftest # 期望 37/37
26
- node tools/route.mjs regress --file cases/route-cases-neutral.json # 期望 26/26
30
+ node tools/route.mjs selftest # 期望 44/44
31
+ node tools/route.mjs regress --file cases/route-cases-neutral.json # 期望 30/30
27
32
  ```
28
33
 
29
34
  要求:**Node 18+**(内置 `fetch` 与 `AbortSignal.timeout`)。零 npm 依赖。
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
3
3
  面向使用者的**版本间净变化**(修复 / 改进 / 新增)。只写最终结果,不写过程。
4
4
  判据、阈值、触发词的任何改动都必须同时更新 `MEASUREMENTS.md` 的数字与 `cases/` 的回归结果。
5
5
 
6
+ ## 1.5.2 — 2026-11-05
7
+
8
+ **修复**
9
+
10
+ - **包更新会把本机配置一起冲掉**:`local.json` / `secrets.json` 原先只从包内 `tools/` 读,而 `dsh plugin update`(以及任何 `npm i`)是**整包替换**,装在包里的本机设置必然消失;又因为 package.json 的 `files` 不含 `local.json`,用户只能在装完后手工往 `node_modules/…/tools/` 里放,每次更新重来一遍。现改为**包外配置目录**:Windows `%LOCALAPPDATA%\dsh-fact-check\`,其它平台 `$XDG_CONFIG_HOME/dsh-fact-check/` 或 `~/.config/dsh-fact-check/`;`JEV_LOCAL_CONFIG` 可显式指定文件。解析顺序 `JEV_LOCAL_CONFIG` → 用户配置目录 → 包内旧位置(兜底,不破坏已有安装)。
11
+ - `tools/doctor.mjs --write` 改为写入包外用户目录(已存在则不覆盖),并在输出里加一行「本机配置:…」显示**实际生效**的文件路径;`route.mjs` / `jev-verdict.mjs` / `doctor.mjs` 的读取全部走同一套解析(新增 `tools/config.mjs`,仍零依赖)。密钥文件同目录解析(`secrets.json`)。
12
+
13
+ **其它**
14
+
15
+ - 文档同步:`ADAPTING.md`(新增「配置文件位置」说明)、`FOR-AI.md`、`README.md`、`README.zh-CN.md`、`tools/local.example.json` 的路径性描述改为包外位置;判据、阈值与流程正文未动。
16
+ - 验收:`route selftest 44/44`、`jev-verdict selftest 25/25`、`smoke-plugin 27/27` 全部通过;解析优先级实测三条:`JEV_LOCAL_CONFIG` 指定路径生效、用户目录生效、两者皆无时 `overrides` 为空且不报错。
17
+ - 升级提示:老安装升级到 1.5.2 后,把原来的 `tools/local.json` 复制到上面那个包外路径即可(包内旧位置仍会被读到,但下次整包替换后就没了)。
18
+
19
+ ## 1.5.1 — 2026-10-28
20
+
21
+ **修复**
22
+
23
+ - **未识别任务的兜底域选错(会劝退该派的活)**:任务文本匹配不到任何触发词时,原先兜底到 `tech_release`——给的是「官方仓库 README/CHANGELOG → 文档站 → 包管理器元数据」这套软件发布阶梯,而且该域 `closed=false`,`dispatchHint` 会说「单路可覆盖,串行处理即可」。对「某平台上两国网民骂战的来龙去脉」这类任务,等于同时给了错误的来源阶梯与错误的派发建议。现改为专用兜底域 `general_fact`:阶梯是「当事方原始发布 → 通讯社与官方广播 → 元搜索交叉 → 存档快照 → 当地媒体 → 社区线索」,`plan` 输出新增 `fallback` 字段,兜底时提示改为「未识别事实类型(兜底域)→ 按 SKILL.md 的信号表判断」。同时补齐两条触发词(`cn_social_rumor` 加 `推特 / Twitter / X 平台 / 骂战 / 对线 / 论战 / 口水战 / 开撕 / 网络战`,`international` 加常见外方国名),让同一类任务不再落兜底。
24
+ - **子代理回报可能被内容审核整条丢弃**:极端题材(族群冲突 / 辱骂 / 暴力)里,子代理逐字复述辱称或族群贬损文本,会让**整条回报**被审核丢弃,那一路渠道的证据就全没了。`SKILL.md`「子代理分发模式(可选)」新增「回报的引文净化」:事实性表述(时间 / 账号名 / tweet_id / 互动量 / 可观察行为特征)照常逐字引;贬损原文改为**中性性质标签 + 原帖 URL + tweet_id + 发布时间**,需要逐字文本时由主对话自己调 syndication / oembed。派发 prompt 必须带上这条约定;回报第一行写 `渠道状态:已取证 / 无命中 / 抓取失败`,把「确实没证据」和「回报丢了」分开。
25
+
26
+ **其它**
27
+
28
+ - `cases/route-cases-neutral.json`:R10 改判 `general_fact` 并反向断言不是 `tech_release`;新增 R27–R30(未识别任务 / X 平台骂战 / 推特对线 / 外方国名进国际域);`regress` 支持 `expectFallback`。
29
+ - `SKILL.md` frontmatter 与 `package.json` 版本号同步到 **1.5.1**;`INSTRUCTIONS.md` 由 `make-instructions.mjs` 重新生成。
30
+ - `MEASUREMENTS.md`:`route` 离线断言 37 → **44 项**、路由语料 26 → **30 例**;§7 增第 10 条记录兜底域修正与两条扩词,并注明 `arms` 的 28/35 与 7 条 mismatch **逐条不变**——新增词条在 35 条共识样本上没改变任何一条判定,样本小,这只说明「未变差」。
31
+ - `ADAPTING.md` / `FOR-AI.md` / `README.md` / `README.zh-CN.md` / `cases/README.md` 里「期望 37/37、26/26」「当前状态」这类**现在时**数字同步到 44/44、30/30;带日期的历史证据行(「某日在该宿主上跑过 37/37」)保持原样,不改写历史。
32
+ - 验收:`selftest 44/44`(原 37)、`regress 30/30`(原 26)、`arms 28/35 = 80.0%` 与 7 条 mismatch **逐条不变**;`jev-verdict selftest 25/25`、`smoke-plugin 27/27` 未受影响。
33
+ - **判据层未动**:`jev-verdict.mjs`、`engine-routes.json`、`lib/` 本轮无改动。
34
+
6
35
  ## 1.5.0 — 2026-10-23
7
36
 
8
37
  **新增**
package/CONTRIBUTING.md CHANGED
@@ -38,8 +38,9 @@ All of these run offline and need no API key.
38
38
 
39
39
  ## Machine-specific values never enter the repository
40
40
 
41
- Endpoints, keys, host paths and "which channel actually works here" belong in `tools/local.json`, which is
42
- git-ignored; `tools/local.example.json` is the template. `tools/*.mjs` carry assertions that fail if a host
41
+ Endpoints, keys, host paths and "which channel actually works here" belong in `local.json` **outside the package**
42
+ (default `%LOCALAPPDATA%\dsh-fact-check\local.json` on Windows, `~/.config/dsh-fact-check/local.json` elsewhere,
43
+ or whatever `JEV_LOCAL_CONFIG` points at); `tools/local.example.json` is the template. `tools/*.mjs` carry assertions that fail if a host
43
44
  path is hard-coded, and `SKILL.md` may not name a host, an internal address, a path or a key.
44
45
 
45
46
  ## Versioning
@@ -36,8 +36,9 @@ node tools/make-instructions.mjs # 只有改了 SKILL.md 才
36
36
 
37
37
  ## 主机相关的值永不入库
38
38
 
39
- 端点、密钥、主机路径,以及「这台机器上哪个渠道真的能用」,一律放 `tools/local.json`(已 gitignore),
40
- 模板是 `tools/local.example.json`。`tools/*.mjs` 里有断言:一旦硬编码主机路径就失败;
39
+ 端点、密钥、主机路径,以及「这台机器上哪个渠道真的能用」,一律放 **包外** 的 `local.json`
40
+ (Windows 默认 `%LOCALAPPDATA%\dsh-fact-check\local.json`,其它平台 `~/.config/dsh-fact-check/local.json`,
41
+ 或用 `JEV_LOCAL_CONFIG` 指定),模板是 `tools/local.example.json`。`tools/*.mjs` 里有断言:一旦硬编码主机路径就失败;
41
42
  `SKILL.md` 里也不得出现主机名、内网地址、路径或密钥。
42
43
 
43
44
  ## 版本纪律
package/FOR-AI.md CHANGED
@@ -46,10 +46,10 @@ node tools/doctor.mjs --net # 联网版:额外自测抓取链与判据端
46
46
  2. 或者生成配置草稿再补空值:
47
47
 
48
48
  ```bash
49
- node tools/doctor.mjs --write # 生成 tools/local.json(已存在则不会覆盖)
49
+ node tools/doctor.mjs --write # 生成配置文件(默认写到包外用户目录;已存在则不会覆盖)
50
50
  ```
51
51
 
52
- 3. 若环境里有自托管搜索 / 代理 / 工具,把它们填进 `tools/local.json` 的 `overrides`:
52
+ 3. 若环境里有自托管搜索 / 代理 / 工具,把它们填进配置文件的 `overrides`:
53
53
 
54
54
  ```json
55
55
  { "overrides": { "SEARXNG_URL": "…", "HTTP_PROXY_URL": "…", "VIDEO_FETCH_CMD": "…", "EXIFTOOL_CMD": "…" } }
@@ -63,7 +63,7 @@ node tools/doctor.mjs --write # 生成 tools/local.json(已存在则不会
63
63
  - `runtimeNotes`:运行时基线,自由文本。最值得记的是"这台机器上哪些渠道真的能用"——例如元搜索实例里哪个引擎被 CAPTCHA 拦、哪些站点必须走代理。这类信息直接决定核查质量,但不适合写进正文。
64
64
 
65
65
  **硬性禁止**(违反即失职):
66
- - 不要把密钥**写进** `SKILL.md`、任何 `.md`、任何数据表、或提交到版本库;`local.json` 已在 `.gitignore` 里。
66
+ - 不要把密钥**写进** `SKILL.md`、任何 `.md`、任何数据表、或提交到版本库;`local.json` 属于本机配置,放**包外**用户配置目录(见 `ADAPTING.md`)。
67
67
  - 不要把密钥**打印**到对话、日志或终端输出里;只需要说明"密钥来源是 X"。
68
68
  - 不要修改 `cases/` 里语料的 `label`(那是校准真值)。
69
69
 
@@ -73,8 +73,8 @@ node tools/doctor.mjs --write # 生成 tools/local.json(已存在则不会
73
73
 
74
74
  ```bash
75
75
  node tools/jev-verdict.mjs selftest # 期望 25/25
76
- node tools/route.mjs selftest # 期望 37/37
77
- node tools/route.mjs regress --file cases/route-cases-neutral.json # 期望 26/26,0 成本
76
+ node tools/route.mjs selftest # 期望 44/44
77
+ node tools/route.mjs regress --file cases/route-cases-neutral.json # 期望 30/30,0 成本
78
78
  node tools/route.mjs arms # 派发臂代理指标,期望 28/35 = 80.0%,0 成本
79
79
 
80
80
  # 有密钥时再跑一次真机,确认端到端可用(约 $0.0003)
@@ -117,7 +117,7 @@ node tools/jev-verdict.mjs regress --file cases/injection-cases-neutral.json --c
117
117
  node tools/route.mjs plan --task "核查一条社交媒体首发的传闻,需要国内外两侧交叉"
118
118
  # → 看 ladders(该找哪些源)/ routes(走哪些渠道)/ failureActions(遇到 403、超时、0 结果怎么办)
119
119
  # → 看 dispatchHint(要不要多路并行取证)
120
- node tools/route.mjs regress --file cases/route-cases-neutral.json # 路由层回归,26 例,0 成本
120
+ node tools/route.mjs regress --file cases/route-cases-neutral.json # 路由层回归,30 例,0 成本
121
121
  node tools/route.mjs arms # 派发臂代理指标(改触发词后必跑),0 成本
122
122
 
123
123
  # ② 判据层(有密钥时):把"证据 → 结论"这件事交给决策模型
@@ -189,5 +189,5 @@ node tools/jev-verdict.mjs batch --file my-cases.json --question support --conc
189
189
  - **把"伪多源"当交叉验证**:多家转载同一篇首发只算 **1** 个独立来源;要追到最上游。
190
190
  - **去痕语料的数字更低**:包里是去痕版,`support` 期望约 37/40(92.5%);原始版是 38–39/40。这不是判据退化,而是"具体出处本身是实据"——见 `MEASUREMENTS.md` §1.1。第二遍去痕(私有工具名/宿主产品名)则**没有**造成可测量的变化。
191
191
  - **阈值附近会漂**:同一批样本两轮结果可能差 1 例,差异都在 p≈0.5 的样本上。这正是"只能降档""unsure 交人"的理由。
192
- - **不要在技能正文里写本机事实**(内网地址、绝对路径、密钥、账号):本机值只进 `local.json`,实测数字只进 `MEASUREMENTS.md`。两个工具的离线自检里有断言在守这条,别删。
192
+ - **不要在技能正文里写本机事实**(内网地址、绝对路径、密钥、账号):本机值只进 `local.json`(包外用户配置目录,见 `ADAPTING.md`),实测数字只进 `MEASUREMENTS.md`。两个工具的离线自检里有断言在守这条,别删。
193
193
  - **工具一律 fail-open**:找不到密钥/超时/HTTP 报错都返回 `unknown` 并回退人工规则,核查流程不应中断。
package/INSTRUCTIONS.md CHANGED
@@ -23,12 +23,12 @@
23
23
  4. **平台专用工具**——视频 / 社交类平台的专用 CLI(字幕、弹幕、评论等);
24
24
  5. **免登录直取接口**——平台公开的免登录端点;拿不到就如实标注"未能获取原文",不要伪造。
25
25
 
26
- 哪条链在本机可用、哪个站已经挂了,属于**运行时基线**:写进 `tools/local.json` 的 `runtimeNotes`(带日期),**不要写进本文**——正文没有时效标注机制,写在这里半年后就是过期信息,而且换台机器就得改正文。
26
+ 哪条链在本机可用、哪个站已经挂了,属于**运行时基线**:写进 `local.json`(包外用户配置目录,见 `ADAPTING.md`)的 `runtimeNotes`(带日期),**不要写进本文**——正文没有时效标注机制,写在这里半年后就是过期信息,而且换台机器就得改正文。
27
27
 
28
28
  **首选路径失败必须自动降级到次选**,而不是直接判定"无法核查"。只有**五条路径全部失败**才判"无法核查",且必须在报告的「本次未能核实到的」里写明试过哪几条、各自报什么错。
29
29
  **搜索结果的 `content` / `snippet` 字段只是摘要,不能当正文计入证据。**
30
30
 
31
- **本技能不绑定任何具体产品、主机或工具链。** 正文只使用"能力名";把能力落到你的环境里见 `ADAPTING.md`;所有"在某台机器上测出来"的数字都在 `MEASUREMENTS.md`,它们**是证据,不是配置**。本机的能力绑定与输出约定可写进 `tools/local.json`(可选字段,见 `tools/local.example.json`),正文只保留默认值。
31
+ **本技能不绑定任何具体产品、主机或工具链。** 正文只使用"能力名";把能力落到你的环境里见 `ADAPTING.md`;所有"在某台机器上测出来"的数字都在 `MEASUREMENTS.md`,它们**是证据,不是配置**。本机的能力绑定与输出约定可写进 `local.json`(包外,可选字段,见 `tools/local.example.json`),正文只保留默认值。
32
32
 
33
33
  自检:`node tools/doctor.mjs` 会探测当前环境有哪些能力、缺什么、会降级成什么样;加 `--net` 会额外自测判据端点与**两条可自测的抓取链**(宿主内置抓取与搜索服务的抽取字段它探不到,需自己各跑一次真实抓取确认)。
34
34
 
@@ -299,6 +299,8 @@
299
299
  | 封闭生态 | 不涉及 | 涉及公众号 / 知乎 / 视频站 / Reddit / 微博等需逐渠道专门取正文 |
300
300
  | 用户口径 | "简单查一下""快点看看" | 明确要"彻底核查""多找些资料" |
301
301
 
302
+ **兜底域不等于简单任务**:`plan` 输出里 `fallback: true` 表示没有命中任何具体事实类型、给的是 `general_fact` 通用阶梯;`closed=false` 只说明「尚未识别出封闭生态」,不代表任务简单。此时仍按上表逐条判,**任一命中右列即派**。(1.5.1 修的正是这处:早期版本把未识别任务兜底到软件发布域 tech_release,并提示「单路可覆盖」,把该派的任务劝退了。)
303
+
302
304
  四种走法:
303
305
 
304
306
  - **轻量直连**:主对话自己多路搜索 + 抓取,本轮内出结论。**轻量 ≠ 降标准**——多源交叉、国内外并行、来源四要素等铁律全部照旧,只是不拆子代理。
@@ -312,8 +314,18 @@
312
314
  - 需要核实的事实点;
313
315
  - **必须遵守的规则**:多源交叉、来源固定四要素(出处名 / 来源类型 / 完整可点击 URL / 引用位置)、原文摘录加引号标"原文"否则标"转述"、配一条质疑词反查、抓不到就如实写"未能获取"而不许编造;
314
316
  - **该渠道的具体抓取手段**(如"Reddit 走 `.json` 端点""视频站走字幕/弹幕工具""X 走 syndication 接口"),避免子代理在登录墙上空转。
317
+ - **「引文净化」约定必须写进派发 prompt**(见下节「回报的引文净化」):子代理的最终回报会经过内容审核,逐字复述辱称或族群贬损文本可能让**整条回报被丢弃**,那一路渠道的证据就全丢了。
318
+
319
+ ### 回报的引文净化
320
+
321
+ 极端题材(族群冲突、辱骂、暴力内容)里,逐字引文既有**失真风险**(转述链上被裁剪成更冒犯的版本)也有**丢回报风险**。回报时统一按这两档处理:
322
+
323
+ - **事实性表述照常逐字引**——时间、账号名、tweet_id、互动量、可观察行为特征(建号时间、发帖节奏、是否模板化),以及任何不含贬损的中性陈述;
324
+ - **贬损性 / 辱骂性原文不逐字复述**——改为**分类描述 + 定位信息**:`该帖含针对特定族群的贬损表述`(或"人身侮辱""辱华/辱土"这类中性的性质标签)+ **原帖 URL + tweet_id + 发布时间**。需要逐字文本时由主对话自己调 syndication / oembed 接口取,不经子代理回报。
325
+
326
+ **这是「原文摘录加引号并注明"原文"」规则在极端题材上的例外**:不引脏文本,但替代它的定位信息必须齐全,否则等于没给证据。这也不是软化结论——**性质、来源、定位信息一个不少**,少的是可被断章引用的原始脏文本;而"存在辱骂"这一事实本身,分类描述 + 可点击 URL 已经足够,且更经得起复核。极端题材建议按渠道数**多派一路冗余**(3→4):某条回报若被整条丢弃,证据不至于全失。
315
327
 
316
- **回报**:每个子代理回报时须标注**来源同源性**(如"本渠道 6 条结果均转载自同一篇首发"),主对话才能正确计算独立源头数。**回报结构统一**:
328
+ **回报**:每个子代理回报时须标注**来源同源性**(如"本渠道 6 条结果均转载自同一篇首发"),主对话才能正确计算独立源头数。回报**第一行写渠道状态**——`渠道状态:已取证 / 无命中 / 抓取失败`——主对话才能区分「这条渠道确实没有证据」与「这条渠道的回报丢了」,不至于把后者当成前者直接降档。**回报结构统一**:
317
329
 
318
330
  ```
319
331
  渠道 → 命中来源清单(带四要素)→ 与主线是否一致 / 矛盾 → 抓取失败的渠道及原因
@@ -330,7 +342,7 @@
330
342
  - 目录结构:**`FOR-AI.md`(交给 AI 的操作说明:逐步配置与使用)** + `SKILL.md`(本文件,流程与规则)+ `ADAPTING.md`(换环境要改什么)+ `MEASUREMENTS.md`(实测数字与复跑)+ `CHANGELOG.md`(版本间净变化)+ `lib/index.js`(DSH 插件入口:把本文件挂到 `ctx.skills`)+ `cordis.patch.yml`(让本包能被 `dsh plugin add` 安装)+ `tools/`(两个可选工具、本机配置模板与数据表)+ `cases/`(回归语料)+ `README.md` / `README.zh-CN.md`(给人类的说明)+ `submission/`(投稿到插件市场的条目文件,不属于插件包本身)。
331
343
  - **两种分发形态同一份正文**:① `dsh plugin add github:7starsseeker/dsh-fact-check`(DSH 插件市场的形态,`lib/index.js` 现读本文件,所以改本文件不必改代码);② 把整个目录交给任何 AI 工具(`FOR-AI.md` 那套)。**不要为其中一种抄一份正文出来**——两份必然漂移,`tools/smoke-plugin.mjs` 里有断言在守"候选描述 = 本文件 frontmatter 的 description"这条单一真相源。
332
344
  - **改判据 = 重新校准事件**:`tools/jev-verdict.mjs` 里的 `QUESTIONS` 是冻结字面量,任何 instruction/criteria/阈值改动都必须重跑 `cases/` 下的回归,并把新数字写回 `MEASUREMENTS.md`。**`tools/route.mjs` 的触发词表与域路由表同理**:改完要重跑 `node tools/route.mjs regress --file cases/route-cases-neutral.json` 与 `node tools/route.mjs selftest`。
333
- - **环境相关的值只放 `tools/local.json`**(模板 `tools/local.example.json`):密钥、端点、state 目录、数据表里的 `${占位符}`,以及可选的能力绑定 `capabilities`、输出约定 `outputConvention`、运行时基线 `runtimeNotes`。代码与数据表**不写死任何主机路径**,`selftest` 里有断言在守这条。
345
+ - **环境相关的值只放 `local.json`**(包外用户配置目录;模板 `tools/local.example.json`):密钥、端点、state 目录、数据表里的 `${占位符}`,以及可选的能力绑定 `capabilities`、输出约定 `outputConvention`、运行时基线 `runtimeNotes`。代码与数据表**不写死任何主机路径**,`selftest` 里有断言在守这条。
334
346
  - **不要在本技能正文里写入任何主机、内网地址、路径或密钥**。本机事实要么进 `local.json`,要么作为"在某环境测得"的证据进 `MEASUREMENTS.md`。
335
347
  - **版本纪律**:凡是改动面向使用者的行为(流程、报告模板、判据、触发词),就升 frontmatter 的 `version` 并在 `CHANGELOG.md` 记一条净变化;只改措辞或文档不算。
336
348
  - **「利益相关与污染面」是标注制,不是降权制**:不要把任何"利益相关 / 疑似投毒 ⇒ 降档"的规则接进定档。至少会同时坏三件事(非单调、奖励信息垄断、全局降档),三条理由是那一节的正文,改之前先重读。
package/MEASUREMENTS.md CHANGED
@@ -114,8 +114,8 @@
114
114
  node tools/doctor.mjs # 先看本机能力与降级路径
115
115
  node tools/doctor.mjs --net # 再真跑一遍抓取链与端点自测
116
116
  node tools/jev-verdict.mjs selftest # 25 项离线断言,不花钱
117
- node tools/route.mjs selftest # 37 项离线断言,不花钱
118
- node tools/route.mjs regress --file cases/route-cases-neutral.json # 路由语料 26 例,不花钱
117
+ node tools/route.mjs selftest # 44 项离线断言,不花钱
118
+ node tools/route.mjs regress --file cases/route-cases-neutral.json # 路由语料 30 例,不花钱
119
119
  node tools/route.mjs arms # 派发臂代理指标(§4.1),不花钱
120
120
 
121
121
  # 本包自带的是(第二遍)去痕版语料
@@ -140,4 +140,5 @@ node tools/jev-verdict.mjs regress --file cases/sufficient-cases-neutral.json
140
140
  7. **`contradicted` ≠ 证伪**:`p < 0.5` 的档位里确实混着真值为"支持"的样本(§1.2),对外只能表述为"证据不足或被反驳,需人工复核"。
141
141
  8. **域路由的触发词是正则,必然有误命中**:`tech_release` 的词表里有「发布」,会把「政策发布会」这类任务拉进技术域(实测 dispatch 语料 D27 因此同时命中 5 个域);`medical` 的单字「药」会把「农药残留」同时拉进医疗域。多命中**不影响结论正确性**(多一个域只是多给几条来源阶梯),但会推高 `dispatchHint` 的域数阈值。1.3.0 新增七个域时按「具体在前」排列以降低串味,既有域的**触发词**一个未动。1.3.0 另修了 `medical` 的**域路由**:原先指向 `official_json_api`(国务院政策文件库 / 法规库),与它阶梯首级的「药品监管机构批准信息」对不上,已改走 `gov_notice`(含国家药监局与市场监管总局)。
142
142
  9. **`factPointsInText` 不是真的拆事实点**:它只数任务文本里 `;;。\n` 的个数再 +1,却被 `dispatchHint` 当判据用(`>= 4` 就建议派发)。一段逗号连写的长任务会被低估,一段分号并列的短任务会被高估。它只够当粗筛,不要拿它当「事实点已拆好」的证据。
143
+ 10. **路由的兜底域(1.5.1 修)**:未命中任何触发词的任务原先兜底到 `tech_release`——那是「官方仓库 / 包管理器元数据」的阶梯,与多数任务无关;而且该域 `closed=false`,`dispatchHint` 会说「单路可覆盖,串行处理即可」,把「封闭生态 / 大量长文」这类真正该派的任务劝退。现改为专用 `general_fact` 兜底阶梯(当事方原始发布 → 通讯社 → 元搜索交叉 → 存档 → 当地媒体 → 社区线索),`plan` 输出新增 `fallback`,兜底时提示改为「按 SKILL.md 的信号表判断」。同一轮补了两条触发词(`cn_social_rumor`:推特 / Twitter / X 平台 / 骂战 / 对线 / 论战 / 口水战 / 开撕 / 网络战;`international`:土耳其 / 希腊 / 俄罗斯 / 越南 / 印度 / 韩国 / 朝鲜 / 以色列 / 巴勒斯坦 / 乌克兰 / 阿根廷 / 巴西 / 墨西哥 / 沙特 / 埃及)。按 §4.1 的代理指标重跑,**28/35 = 80.0% 与 7 条 mismatch(D08 / D10 / D12 / D15 / D27 / D30 / D37)逐条不变**——新增词条在 35 条共识样本上没改变任何一条判定;样本小,这只说明「未变差」,不等于扩词已在通用任务上验证。
143
144
 
package/README.md CHANGED
@@ -68,7 +68,7 @@ are kept in their own sections — *unverified* and *undecidable* never sit in t
68
68
  The skill body is not bound to any host, product or tool chain: it names *capabilities*, and
69
69
  [`ADAPTING.md`](./ADAPTING.md) maps them onto your environment.
70
70
 
71
- **Verified hosts: DSH 0.1.7-rc.2, 0.2.0-rc.2 and 0.2.1-rc.2.** DSH 0.1.7-rc.2 was checked end to end on 2026-09-25 (Node 24.21.0, WSL/Linux): the host discovers the skill
71
+ **Verified hosts: DSH 0.1.7-rc.2, 0.2.0-rc.2, 0.2.1-rc.2, 0.2.1-rc.3 and 0.2.1-rc.5.** DSH 0.1.7-rc.2 was checked end to end on 2026-09-25 (Node 24.21.0, WSL/Linux): the host discovers the skill
72
72
  and loads its body; the plugin form mounts on the host's own skill registry with `source: bundled`, its
73
73
  description read from `SKILL.md` and `get()` returning that body verbatim; `npm run selftest` passes
74
74
  (plugin mount 27/27, `jev-verdict` 25/25, `route` 37/37); `route.mjs regress` is 26/26 offline; and the
@@ -83,6 +83,12 @@ forbidden.
83
83
 
84
84
  **DSH 0.2.1-rc.2 was checked end to end on 2026-10-17** (Node 24.21.0, Windows). The registry implementation (`@deepseek-ai/dsh-skill`'s `lib/index.js`) has **no content change** between 0.2.0-rc.2 and 0.2.1-rc.2 — the diff is line endings and version metadata only (`package.json` bumps the peers to cordis `~4.0.5` and adds a `./locale/*.json` export) — so no API adaptation was needed. What did change is what a provider may do with `control`, and this package now uses both 0.2.1 affordances: the candidate carries `path` (the absolute `SKILL.md`, retained in catalog summaries), and the provider watches `SKILL.md` and calls `control.invalidate()` on save, so editing the body refreshes the session catalog without a restart (150 ms debounce; the watcher is torn down on `control.signal` abort or context dispose, and a host that passes no `control` keeps the previous "restart to re-read" behavior). Evidence: the plugin was mounted on the host's own `0.2.1-rc.2` registry code (taken from the shipped asar, wired with the host's own cordis 4.0.5) and on the **published** `0.2.0-rc.2` package — 21/21 checks on each, `snapshot.complete === true`, `get()` returning the body verbatim, `provider.list()` rank 600, and both edit→refresh cycles observed; `npm run selftest` 27/27, 25/25, 37/37 and `route.mjs regress` 26/26 re-run on Node 24.21.0.
85
85
 
86
+ **DSH 0.2.1-rc.3 was checked on 2026-10-24** (Node 24.22.1, Windows). Nothing in the registry contract moved: `@deepseek-ai/dsh-skill` 0.2.1-rc.3 still exports `BUNDLED_SKILL_RANK` (600), `SkillRegistry`, `isSkillName`, `isModelInvocable` and `isUserInvocable`, still hands `registerProvider()` the same `control` (`{ signal, invalidate }`), and `invalidateCache()` still advances the cache revision and broadcasts `skills/change`. The plugin was mounted on that registry — taken from the shipped `app.asar`, wired with the host's own cordis 4.0.5 — and passed **28/28** checks: `inject: ['skills']`; the candidate carrying `source: bundled`, `rank` 600 equal to the host's own exported constant, the absolute `SKILL.md` as `path`, the package root as `resourceBase`, and the frontmatter description verbatim; `get()` returning the body (13,533 characters, frontmatter stripped, `path` retained); `control.invalidate()` advancing the registry revision 1 → 2 and emitting exactly one `skills/change`; and disposal removing the provider and aborting `control.signal`. Two details worth writing down because they look like failures and are not: the host's `toSummary()` deliberately drops `rank` from catalog summaries (it is a same-name arbitration input, not a display field), and registration only becomes observable once `ctx.plugin()`'s thenable resolves — inspected before that await, even a successful mount shows no `control`. In this same rc3 session the **directory** form is the one that runs (the host's own skill directory), and the session's skill catalog lists `fact-check` with this repository's `SKILL.md` description.
87
+
88
+ **DSH 0.2.1-rc.5 was checked on 2026-10-31** (Node 26.13.0, Windows; the host version is the one the shipped `app.asar`'s `@deepseek-ai/dsh-desktop` declares, and `dsh --version` reports the same `0.2.1-rc.5`). The registry contract did not move again: `@deepseek-ai/dsh-skill` 0.2.1-rc.5 still exports `BUNDLED_SKILL_RANK` (600) together with the same `SkillRegistry` / `isSkillName` / `isModelInvocable` / `isUserInvocable` surface, still hands `registerProvider()` the same `control` (`{ signal, invalidate }`), and `validateCandidate`, `toSummary` and `renderSkillContent` behave as in 0.2.1-rc.3 — checked line by line against the file taken from the shipped `app.asar`, wired with the host's own cordis 4.0.5. The plugin mounted on that registry and passed **24/24** checks: `inject: ['skills']`; exactly one candidate for `fact-check`, carrying `source: bundled`, `rank` 600 equal to the host's exported constant, the absolute `SKILL.md` as `path` and the package root as `resourceBase`; `get()` returning the body (14,462 characters, frontmatter stripped); that body rendering through `renderSkillContent` with the package root as the resource base; `control.invalidate()` making an edited description visible to the catalogue (edited and reverted inside the probe — `git status --porcelain` empty afterwards); and disposal removing the provider again. The bundled suite re-ran green on Node 26.13.0: `smoke-plugin` 27/27, `jev-verdict selftest` 25/25, `route selftest` 44/44, `route.mjs regress` 30/30 offline, `doctor` exit 0.
89
+
90
+ One host behaviour added in this release decides whether *any* plugin loads, so it is recorded here. `@deepseek-ai/dsh-app-boot` gained a plugin↔runtime compatibility gate: a plugin that declares `engines.dsh` must satisfy it; a plugin that declares no `engines.dsh` but *does* declare `peerDependencies` must satisfy every `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peer; **a plugin with neither passes unconditionally**. Incompatible profile bundles are skipped without printing anything, and incompatible `cordis.patch.yml` rows are set `disabled`. This package declares only `engines.node` and no `peerDependencies`, so the gate cannot deny it on any runtime — a second reason, after the plugin-market one above, to keep the manifest free of host versions.
91
+
86
92
  ## Install
87
93
 
88
94
  ### As a DSH plugin
@@ -129,7 +135,7 @@ Two optional offline tools sit alongside it:
129
135
 
130
136
  | Tool | What it does |
131
137
  |---|---|
132
- | `node tools/route.mjs plan --task "…"` | Deterministic planning: which source ladder and which channels this kind of claim needs, plus the failure-action table. A pure data table — no model, no tokens. |
138
+ | `node tools/route.mjs plan --task "…"` | Deterministic planning: which source ladder and which channels this kind of claim needs, plus the failure-action table. A pure data table — no model, no tokens. A claim that matches no known fact type still gets the general fact-check ladder (flagged `fallback: true` in the output), never a software-release one. |
133
139
  | `node tools/jev-verdict.mjs` | The judgement client described below. |
134
140
 
135
141
  ## The optional Jev decision layer
@@ -146,8 +152,8 @@ Three judgements are inherently about facts rather than about prose, and the ski
146
152
  Two things about it are deliberate:
147
153
 
148
154
  - **It is optional.** With no key configured, the same three judgements are made by the deterministic
149
- rules written in `SKILL.md`; nothing else changes. A key can come from `tools/local.json`, from the
150
- `TYPESAFE_API_KEY` environment variable, or from `tools/jev-verdict.mjs --key-file`.
155
+ rules written in `SKILL.md`; nothing else changes. A key can come from the local config
156
+ (`local.json`), from the `TYPESAFE_API_KEY` environment variable, or from `tools/jev-verdict.mjs --key-file`.
151
157
  - **A model verdict can only lower a grade, never raise it.** The conclusion tier is decided by the
152
158
  deterministic rule *number of independent sources → tier*; a model saying "the evidence supports this"
153
159
  promotes nothing. Only "insufficient evidence" or "contradicts the evidence" triggers an action.
@@ -172,7 +178,7 @@ node tools/doctor.mjs # what this machine can and
172
178
 
173
179
  `node tools/doctor.mjs --net` additionally self-tests the fetch chains and the judgement endpoint, and
174
180
  reports which capabilities are missing and how the skill degrades without them. Current status on this
175
- checkout: **27/27**, **25/25**, **37/37** and **26/26** assertions pass.
181
+ checkout: **27/27**, **25/25**, **44/44** and **30/30** assertions pass.
176
182
 
177
183
  CI runs exactly these commands on Linux and Windows, on Node 18 and 22, plus one more check: that the
178
184
  generated `INSTRUCTIONS.md` still matches `SKILL.md`.
@@ -197,7 +203,9 @@ generated `INSTRUCTIONS.md` still matches `SKILL.md`.
197
203
  ## Configuration
198
204
 
199
205
  Machine-specific values — endpoints, keys, which channels actually work on your machine — live in
200
- `tools/local.json`, which is git-ignored and shipped only as [`tools/local.example.json`](./tools/local.example.json).
206
+ `local.json` **outside the package** (default `%LOCALAPPDATA%\dsh-fact-check\local.json` on Windows,
207
+ `~/.config/dsh-fact-check/local.json` elsewhere; override with `JEV_LOCAL_CONFIG`), so a package update
208
+ cannot wipe them. The template ships as [`tools/local.example.json`](./tools/local.example.json).
201
209
  No host path, endpoint or key is hard-coded in the code or the data tables, and `tools/*.mjs` carry
202
210
  assertions that fail if one ever appears.
203
211
 
package/README.zh-CN.md CHANGED
@@ -62,7 +62,7 @@
62
62
  技能正文不绑定任何产品、主机或工具链:它只写**能力名**,由 [`ADAPTING.md`](./ADAPTING.md) 把能力
63
63
  落到你的环境里。
64
64
 
65
- **已验证宿主:DSH 0.1.7-rc.2、0.2.0-rc.2 与 0.2.1-rc.2。** 0.1.7-rc.2 于 2026-09-25 完整跑过一遍(Node 24.21.0,WSL/Linux):宿主能发现本技能并读入正文;
65
+ **已验证宿主:DSH 0.1.7-rc.2、0.2.0-rc.2、0.2.1-rc.2、0.2.1-rc.3 与 0.2.1-rc.5。** 0.1.7-rc.2 于 2026-09-25 完整跑过一遍(Node 24.21.0,WSL/Linux):宿主能发现本技能并读入正文;
66
66
  插件形态能挂到宿主自己的技能注册表上,`source: bundled`,描述取自 `SKILL.md`,`get()` 交出的正文
67
67
  与 `SKILL.md` 逐字一致;`npm run selftest` 全过(插件挂载 27/27、`jev-verdict` 25/25、`route` 37/37);
68
68
  `route.mjs regress` 离线 26/26;判据语料复现 [`MEASUREMENTS.md`](./MEASUREMENTS.md) §6 的预期 ——
@@ -75,6 +75,12 @@
75
75
 
76
76
  **DSH 0.2.1-rc.2 于 2026-10-17 完整跑过一遍**(Node 24.21.0,Windows)。注册表的实现(`@deepseek-ai/dsh-skill` 的 `lib/index.js`)在 0.2.0-rc.2 与 0.2.1-rc.2 之间**内容没有变化** —— diff 只有换行符与版本号(`package.json` 把 peer 依赖抬到 cordis `~4.0.5`、加了一个 `./locale/*.json` 导出)—— 所以不需要任何 API 适配。变的是 provider 能拿 `control` **做什么**,本包两样都用上了:候选带上 `path`(指向绝对的 `SKILL.md`,摘要会保留它);provider 监听 `SKILL.md`,保存时调 `control.invalidate()`,改正文不必重启就会刷新会话里的技能目录(150 ms 去抖;`control.signal` abort 或上下文销毁时收尾,宿主不传 `control` 时退化为「改完重启生效」)。证据:插件分别挂到宿主自己那套 `0.2.1-rc.2` 注册表代码(从已发布的 asar 里取出,配宿主自带的 cordis 4.0.5)与**已发布**的 `0.2.0-rc.2` 包上,各 21/21 项检查通过:`snapshot.complete === true`、`get()` 交出的正文与 `SKILL.md` 逐字一致、`provider.list()` 的 rank 为 600,两轮「改文件 → 目录自动刷新」都观察到了;`npm run selftest` 27/27、25/25、37/37 与 `route.mjs regress` 26/26 均在 Node 24.21.0 上复跑。
77
77
 
78
+ **DSH 0.2.1-rc.3 于 2026-10-24 完整跑过一遍**(Node 24.22.1,Windows)。注册表契约没有位移:`@deepseek-ai/dsh-skill` 0.2.1-rc.3 仍导出 `BUNDLED_SKILL_RANK`(600)、`SkillRegistry`、`isSkillName`、`isModelInvocable` 与 `isUserInvocable`,`registerProvider()` 交出的 `control`(`{ signal, invalidate }`)不变,`invalidateCache()` 仍推进缓存 revision 并广播 `skills/change`。插件挂在这套注册表上(取自随宿主发布的 `app.asar`,配宿主自己的 cordis 4.0.5)通过了 **28/28** 项检查:`inject: ['skills']`;候选带 `source: bundled`、`rank` 600 等于宿主导出的常量、绝对 `SKILL.md` 作为 `path`、包根目录作为 `resourceBase`、描述与 frontmatter 逐字一致;`get()` 交出正文(13,533 字符,frontmatter 已剥离,`path` 保留);`control.invalidate()` 让注册表 revision 从 1 到 2 并恰好广播一次 `skills/change`;拆卸后 provider 消失且 `control.signal` 已 abort。两处看着像失败、其实不是的细节也一并记下:宿主的 `toSummary()` 有意不在目录摘要里带 `rank`(它是同名仲裁的输入,不是展示字段);注册只在 `ctx.plugin()` 的 thenable 落定后可观测 —— 在 await 之前去看,即便挂载成功也看不到 `control`。同一次 rc3 会话里真正运行的是**目录形态**(宿主自己的技能目录),会话的技能目录用本仓 `SKILL.md` 的描述列出了 `fact-check`。
79
+
80
+ **DSH 0.2.1-rc.5 于 2026-10-31 完整跑过一遍**(Node 26.13.0,Windows;宿主版本以随宿主发布的 `app.asar` 里 `@deepseek-ai/dsh-desktop` 声明的为准,`dsh --version` 报的也是同一个 `0.2.1-rc.5`)。注册表契约再次没有位移:`@deepseek-ai/dsh-skill` 0.2.1-rc.5 仍导出 `BUNDLED_SKILL_RANK`(600)与同一套 `SkillRegistry` / `isSkillName` / `isModelInvocable` / `isUserInvocable`,`registerProvider()` 交出的 `control`(`{ signal, invalidate }`)不变,`validateCandidate`、`toSummary`、`renderSkillContent` 的行为与 0.2.1-rc.3 一致 —— 均逐行对着从 `app.asar` 取出的那份文件、配宿主自己的 cordis 4.0.5 核过。插件挂在这套注册表上通过了 **24/24** 项检查:`inject: ['skills']`;`fact-check` 恰好一条候选,带 `source: bundled`、`rank` 600 等于宿主导出的常量、绝对 `SKILL.md` 作为 `path`、包根目录作为 `resourceBase`;`get()` 交出正文(14,462 字符,frontmatter 已剥离);该正文经 `renderSkillContent` 可正常渲染且资源基准为包根;`control.invalidate()` 让改动过的描述在目录里可见(探针内改完即还原,事后 `git status --porcelain` 为空);拆卸后 provider 再次消失。仓库自带套件在 Node 26.13.0 上复跑全绿:`smoke-plugin` 27/27、`jev-verdict selftest` 25/25、`route selftest` 44/44、`route.mjs regress` 离线 30/30、`doctor` exit 0。
81
+
82
+ 这一版新增的一条宿主行为决定**任何**插件能不能加载,所以记在这里。`@deepseek-ai/dsh-app-boot` 加了插件↔运行时兼容门禁:声明了 `engines.dsh` 的插件必须满足它;没有 `engines.dsh` 但**有** `peerDependencies` 的插件,其中凡是 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 的 peer 都必须满足;**两者都没有的插件无条件通过**。不兼容的 profile bundle 会被静默跳过、不打印任何东西,不兼容的 `cordis.patch.yml` 行会被置 `disabled`。本包只声明 `engines.node`、没有 `peerDependencies`,所以门禁在任何运行时上都无法拒载它 —— 这是除上面那条插件市场理由之外,又一条把 manifest 保持为「不声明宿主版本」的理由。
83
+
78
84
  ## 安装
79
85
 
80
86
  ### 作为 DSH 插件
@@ -119,7 +125,7 @@ dsh plugin add ./dsh-fact-check
119
125
 
120
126
  | 工具 | 做什么 |
121
127
  |---|---|
122
- | `node tools/route.mjs plan --task "…"` | 确定性规划:这类核查该走哪条来源阶梯、哪些渠道,以及失败动作表。纯数据表,无模型、0 token。 |
128
+ | `node tools/route.mjs plan --task "…"` | 确定性规划:这类核查该走哪条来源阶梯、哪些渠道,以及失败动作表。纯数据表,无模型、0 token。未命中任何已知事实类型的任务也照样给阶梯——给的是通用事实核查阶梯(输出里 `fallback: true`),不会是软件发布那套。 |
123
129
  | `node tools/jev-verdict.mjs` | 下面说的判据客户端。 |
124
130
 
125
131
  ## 可选的 Jev 判据层
@@ -136,7 +142,7 @@ dsh plugin add ./dsh-fact-check
136
142
  两件事是刻意的:
137
143
 
138
144
  - **它是可选的。** 没配密钥时,这三件事由 `SKILL.md` 里写死的确定性规则执行,其余流程完全不变。
139
- 密钥可以来自 `tools/local.json`、环境变量 `TYPESAFE_API_KEY`,或 `tools/jev-verdict.mjs --key-file`。
145
+ 密钥可以来自本机配置 `local.json`、环境变量 `TYPESAFE_API_KEY`,或 `tools/jev-verdict.mjs --key-file`。
140
146
  - **模型的判断只能降档,不能升档。** 结论档位由确定性规则「独立来源数 → 档位」裁定;模型说「支持」
141
147
  不会让任何东西升级。只有「证据不足」或「与证据矛盾」才触发动作。
142
148
 
@@ -157,7 +163,7 @@ node tools/doctor.mjs # 这台机器能做什么
157
163
  ```
158
164
 
159
165
  `node tools/doctor.mjs --net` 会额外自测抓取链与判据端点,并报告缺哪些能力、缺了会降级成什么样。
160
- 本检出上的当前状态:**27/27**、**25/25**、**37/37**、**26/26** 断言全过。
166
+ 本检出上的当前状态:**27/27**、**25/25**、**44/44**、**30/30** 断言全过。
161
167
 
162
168
  CI 在 Linux 与 Windows 上、Node 18 与 22 下跑的就是上面这几条,另外多一条检查:生成的
163
169
  `INSTRUCTIONS.md` 是否仍然与 `SKILL.md` 一致。
@@ -181,8 +187,10 @@ CI 在 Linux 与 Windows 上、Node 18 与 22 下跑的就是上面这几条,
181
187
 
182
188
  ## 配置
183
189
 
184
- 主机相关的值——端点、密钥、你那台机器上哪些渠道真的能用——一律放 `tools/local.json`,它被 gitignore,
185
- 只分发 [`tools/local.example.json`](./tools/local.example.json)。代码与数据表里**不写死**任何主机路径、
190
+ 主机相关的值——端点、密钥、你那台机器上哪些渠道真的能用——一律放 `local.json`,它在**包外**
191
+ (默认 `%LOCALAPPDATA%\dsh-fact-check\local.json`,其它平台 `~/.config/dsh-fact-check/local.json`,
192
+ 可用 `JEV_LOCAL_CONFIG` 覆盖),这样包更新不会把它清掉。模板只分发
193
+ [`tools/local.example.json`](./tools/local.example.json)。代码与数据表里**不写死**任何主机路径、
186
194
  端点或密钥,`tools/*.mjs` 里有断言专门守这条。
187
195
 
188
196
  ## 参与贡献
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fact-check
3
- version: 1.5.0
3
+ version: 1.5.2
4
4
  description: 事实核查与资料搜索。当用户要求核实事实/传闻/新闻/数据/情况的真伪、查证信息、确认现状、补充资料时使用——即使用户只说了"查一下""确认一下""核实""这是不是真的""帮我看看",也应触发。核心行为:不依赖自身知识库,所有关键信息一律通过互联网搜索获取,搜索途径与信息来源尽量多样以便交叉核实,并同时覆盖国内与国外信息生态。执行模式按工作量自动选择:轻量核查由主对话直连完成,渠道多或需抓取大量长文时自动分派并行子代理。
5
5
  ---
6
6
 
@@ -24,12 +24,12 @@ description: 事实核查与资料搜索。当用户要求核实事实/传闻/
24
24
  4. **平台专用工具**——视频 / 社交类平台的专用 CLI(字幕、弹幕、评论等);
25
25
  5. **免登录直取接口**——平台公开的免登录端点;拿不到就如实标注"未能获取原文",不要伪造。
26
26
 
27
- 哪条链在本机可用、哪个站已经挂了,属于**运行时基线**:写进 `tools/local.json` 的 `runtimeNotes`(带日期),**不要写进本文**——正文没有时效标注机制,写在这里半年后就是过期信息,而且换台机器就得改正文。
27
+ 哪条链在本机可用、哪个站已经挂了,属于**运行时基线**:写进 `local.json`(包外用户配置目录,见 `ADAPTING.md`)的 `runtimeNotes`(带日期),**不要写进本文**——正文没有时效标注机制,写在这里半年后就是过期信息,而且换台机器就得改正文。
28
28
 
29
29
  **首选路径失败必须自动降级到次选**,而不是直接判定"无法核查"。只有**五条路径全部失败**才判"无法核查",且必须在报告的「本次未能核实到的」里写明试过哪几条、各自报什么错。
30
30
  **搜索结果的 `content` / `snippet` 字段只是摘要,不能当正文计入证据。**
31
31
 
32
- **本技能不绑定任何具体产品、主机或工具链。** 正文只使用"能力名";把能力落到你的环境里见 `ADAPTING.md`;所有"在某台机器上测出来"的数字都在 `MEASUREMENTS.md`,它们**是证据,不是配置**。本机的能力绑定与输出约定可写进 `tools/local.json`(可选字段,见 `tools/local.example.json`),正文只保留默认值。
32
+ **本技能不绑定任何具体产品、主机或工具链。** 正文只使用"能力名";把能力落到你的环境里见 `ADAPTING.md`;所有"在某台机器上测出来"的数字都在 `MEASUREMENTS.md`,它们**是证据,不是配置**。本机的能力绑定与输出约定可写进 `local.json`(包外,可选字段,见 `tools/local.example.json`),正文只保留默认值。
33
33
 
34
34
  自检:`node tools/doctor.mjs` 会探测当前环境有哪些能力、缺什么、会降级成什么样;加 `--net` 会额外自测判据端点与**两条可自测的抓取链**(宿主内置抓取与搜索服务的抽取字段它探不到,需自己各跑一次真实抓取确认)。
35
35
 
@@ -300,6 +300,8 @@ description: 事实核查与资料搜索。当用户要求核实事实/传闻/
300
300
  | 封闭生态 | 不涉及 | 涉及公众号 / 知乎 / 视频站 / Reddit / 微博等需逐渠道专门取正文 |
301
301
  | 用户口径 | "简单查一下""快点看看" | 明确要"彻底核查""多找些资料" |
302
302
 
303
+ **兜底域不等于简单任务**:`plan` 输出里 `fallback: true` 表示没有命中任何具体事实类型、给的是 `general_fact` 通用阶梯;`closed=false` 只说明「尚未识别出封闭生态」,不代表任务简单。此时仍按上表逐条判,**任一命中右列即派**。(1.5.1 修的正是这处:早期版本把未识别任务兜底到软件发布域 tech_release,并提示「单路可覆盖」,把该派的任务劝退了。)
304
+
303
305
  四种走法:
304
306
 
305
307
  - **轻量直连**:主对话自己多路搜索 + 抓取,本轮内出结论。**轻量 ≠ 降标准**——多源交叉、国内外并行、来源四要素等铁律全部照旧,只是不拆子代理。
@@ -313,8 +315,18 @@ description: 事实核查与资料搜索。当用户要求核实事实/传闻/
313
315
  - 需要核实的事实点;
314
316
  - **必须遵守的规则**:多源交叉、来源固定四要素(出处名 / 来源类型 / 完整可点击 URL / 引用位置)、原文摘录加引号标"原文"否则标"转述"、配一条质疑词反查、抓不到就如实写"未能获取"而不许编造;
315
317
  - **该渠道的具体抓取手段**(如"Reddit 走 `.json` 端点""视频站走字幕/弹幕工具""X 走 syndication 接口"),避免子代理在登录墙上空转。
318
+ - **「引文净化」约定必须写进派发 prompt**(见下节「回报的引文净化」):子代理的最终回报会经过内容审核,逐字复述辱称或族群贬损文本可能让**整条回报被丢弃**,那一路渠道的证据就全丢了。
319
+
320
+ ### 回报的引文净化
321
+
322
+ 极端题材(族群冲突、辱骂、暴力内容)里,逐字引文既有**失真风险**(转述链上被裁剪成更冒犯的版本)也有**丢回报风险**。回报时统一按这两档处理:
323
+
324
+ - **事实性表述照常逐字引**——时间、账号名、tweet_id、互动量、可观察行为特征(建号时间、发帖节奏、是否模板化),以及任何不含贬损的中性陈述;
325
+ - **贬损性 / 辱骂性原文不逐字复述**——改为**分类描述 + 定位信息**:`该帖含针对特定族群的贬损表述`(或"人身侮辱""辱华/辱土"这类中性的性质标签)+ **原帖 URL + tweet_id + 发布时间**。需要逐字文本时由主对话自己调 syndication / oembed 接口取,不经子代理回报。
326
+
327
+ **这是「原文摘录加引号并注明"原文"」规则在极端题材上的例外**:不引脏文本,但替代它的定位信息必须齐全,否则等于没给证据。这也不是软化结论——**性质、来源、定位信息一个不少**,少的是可被断章引用的原始脏文本;而"存在辱骂"这一事实本身,分类描述 + 可点击 URL 已经足够,且更经得起复核。极端题材建议按渠道数**多派一路冗余**(3→4):某条回报若被整条丢弃,证据不至于全失。
316
328
 
317
- **回报**:每个子代理回报时须标注**来源同源性**(如"本渠道 6 条结果均转载自同一篇首发"),主对话才能正确计算独立源头数。**回报结构统一**:
329
+ **回报**:每个子代理回报时须标注**来源同源性**(如"本渠道 6 条结果均转载自同一篇首发"),主对话才能正确计算独立源头数。回报**第一行写渠道状态**——`渠道状态:已取证 / 无命中 / 抓取失败`——主对话才能区分「这条渠道确实没有证据」与「这条渠道的回报丢了」,不至于把后者当成前者直接降档。**回报结构统一**:
318
330
 
319
331
  ```
320
332
  渠道 → 命中来源清单(带四要素)→ 与主线是否一致 / 矛盾 → 抓取失败的渠道及原因
@@ -331,7 +343,7 @@ description: 事实核查与资料搜索。当用户要求核实事实/传闻/
331
343
  - 目录结构:**`FOR-AI.md`(交给 AI 的操作说明:逐步配置与使用)** + `SKILL.md`(本文件,流程与规则)+ `ADAPTING.md`(换环境要改什么)+ `MEASUREMENTS.md`(实测数字与复跑)+ `CHANGELOG.md`(版本间净变化)+ `lib/index.js`(DSH 插件入口:把本文件挂到 `ctx.skills`)+ `cordis.patch.yml`(让本包能被 `dsh plugin add` 安装)+ `tools/`(两个可选工具、本机配置模板与数据表)+ `cases/`(回归语料)+ `README.md` / `README.zh-CN.md`(给人类的说明)+ `submission/`(投稿到插件市场的条目文件,不属于插件包本身)。
332
344
  - **两种分发形态同一份正文**:① `dsh plugin add github:7starsseeker/dsh-fact-check`(DSH 插件市场的形态,`lib/index.js` 现读本文件,所以改本文件不必改代码);② 把整个目录交给任何 AI 工具(`FOR-AI.md` 那套)。**不要为其中一种抄一份正文出来**——两份必然漂移,`tools/smoke-plugin.mjs` 里有断言在守"候选描述 = 本文件 frontmatter 的 description"这条单一真相源。
333
345
  - **改判据 = 重新校准事件**:`tools/jev-verdict.mjs` 里的 `QUESTIONS` 是冻结字面量,任何 instruction/criteria/阈值改动都必须重跑 `cases/` 下的回归,并把新数字写回 `MEASUREMENTS.md`。**`tools/route.mjs` 的触发词表与域路由表同理**:改完要重跑 `node tools/route.mjs regress --file cases/route-cases-neutral.json` 与 `node tools/route.mjs selftest`。
334
- - **环境相关的值只放 `tools/local.json`**(模板 `tools/local.example.json`):密钥、端点、state 目录、数据表里的 `${占位符}`,以及可选的能力绑定 `capabilities`、输出约定 `outputConvention`、运行时基线 `runtimeNotes`。代码与数据表**不写死任何主机路径**,`selftest` 里有断言在守这条。
346
+ - **环境相关的值只放 `local.json`**(包外用户配置目录;模板 `tools/local.example.json`):密钥、端点、state 目录、数据表里的 `${占位符}`,以及可选的能力绑定 `capabilities`、输出约定 `outputConvention`、运行时基线 `runtimeNotes`。代码与数据表**不写死任何主机路径**,`selftest` 里有断言在守这条。
335
347
  - **不要在本技能正文里写入任何主机、内网地址、路径或密钥**。本机事实要么进 `local.json`,要么作为"在某环境测得"的证据进 `MEASUREMENTS.md`。
336
348
  - **版本纪律**:凡是改动面向使用者的行为(流程、报告模板、判据、触发词),就升 frontmatter 的 `version` 并在 `CHANGELOG.md` 记一条净变化;只改措辞或文档不算。
337
349
  - **「利益相关与污染面」是标注制,不是降权制**:不要把任何"利益相关 / 疑似投毒 ⇒ 降档"的规则接进定档。至少会同时坏三件事(非单调、奖励信息垄断、全局降档),三条理由是那一节的正文,改之前先重读。
package/cases/README.md CHANGED
@@ -35,7 +35,7 @@
35
35
  | `dispatch-blind-neutral.json` | 40 | —(仅 id + 任务描述,无标签) | 派发决策的**输入** |
36
36
  | `dispatch-label-L1-neutral.json` / `-L2-neutral.json` | 40×2 | 两名独立盲标注员 | 两人一致 35/40 = **87.5%**;不一致的 5 条即"口径本身有歧义" |
37
37
  | `dispatch-label-LLM-neutral.json` | 40 | 一个 LLM 用同一份判据作答 | 作为"派发决策让 LLM 现判"的对照臂 |
38
- | `route-cases-neutral.json` | 26 | 任务描述 → 期望命中的域 / 路线(按路由表机械裁定) | **26/26**(`route.mjs regress`,0 成本、不调模型、不出网) |
38
+ | `route-cases-neutral.json` | 30 | 任务描述 → 期望命中的域 / 路线(按路由表机械裁定) | **30/30**(`route.mjs regress`,0 成本、不调模型、不出网) |
39
39
 
40
40
  前六套语料打的是**判据层**(`jev-verdict.mjs`,要密钥、有成本);最后一套打的是**路由层**(`route.mjs`,纯数据表、0 成本)。路由层同样属于"判据":**改触发词表或域路由表 = 重新校准事件**,必须重跑 `route.mjs regress` 与 `route.mjs selftest`。
41
41
 
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "_about": "路由层回归语料(不调模型、不出网、0 成本)。每条给一个任务描述与若干 expect* 断言,检查 route.mjs 的确定性路由是否命中该命中的域、避开不该命中的域。跑法:node tools/route.mjs regress --file cases/route-cases-neutral.json",
3
- "_generated_by": "2026-09-21 新增(补上游只有 selftest 断言、没有独立路由语料的缺口;触发词一改就必须重跑这里);1.3.0 起随七个新域扩到 26 例",
3
+ "_generated_by": "2026-09-21 新增(补上游只有 selftest 断言、没有独立路由语料的缺口;触发词一改就必须重跑这里);1.3.0 起随七个新域扩到 26 例;1.5.1 修兜底域后扩到 30 例(R10 改判 general_fact,新增 R27–R30)",
4
4
  "_notes": [
5
5
  "域是并列累积的:一个任务可以同时命中多个域,expectDomains 只要求包含(子集检查),不要求独占",
6
- "expectClosed / expectDispatch 对应 plan 输出的 closedEcosystem / dispatchHint",
6
+ "expectClosed / expectFallback / expectDispatch 对应 plan 输出的 closedEcosystem / fallback / dispatchHint",
7
7
  "触发词表属于「判据」的一部分:改动触发词 = 重新校准事件,必须重跑本语料并记录",
8
- "R15 起覆盖 1.3.0 新增的七个域(司法 / 灾害 / 食安 / 科学常识 / 教育考试 / 体育 / 娱乐),并检查相邻域不串味"
8
+ "R15 起覆盖 1.3.0 新增的七个域(司法 / 灾害 / 食安 / 科学常识 / 教育考试 / 体育 / 娱乐),并检查相邻域不串味",
9
+ "R10 / R27 断言未识别任务落到通用兜底域 general_fact 而不是软件发布域 tech_release;R28 / R29 覆盖 X 平台 / 推特上的骂战与对线(识别社媒域 + 封闭生态);R30 覆盖外方国名进国际域"
9
10
  ],
10
11
  "cases": [
11
12
  {
@@ -63,7 +64,9 @@
63
64
  {
64
65
  "id": "R10",
65
66
  "task": "???",
66
- "expectDomains": ["tech_release"]
67
+ "expectDomains": ["general_fact"],
68
+ "expectNotDomains": ["tech_release"],
69
+ "expectFallback": true
67
70
  },
68
71
  {
69
72
  "id": "R11",
@@ -159,6 +162,31 @@
159
162
  "task": "某球员转会费创纪录,核实金额是否属实",
160
163
  "expectDomains": ["sports"],
161
164
  "expectNotDomains": ["finance"]
165
+ },
166
+ {
167
+ "id": "R27",
168
+ "task": "今天天气不错",
169
+ "expectDomains": ["general_fact"],
170
+ "expectNotDomains": ["tech_release"],
171
+ "expectFallback": true
172
+ },
173
+ {
174
+ "id": "R28",
175
+ "task": "确认这几天 X 平台上两国网民骂战的来龙去脉和目前的状况",
176
+ "expectDomains": ["cn_social_rumor"],
177
+ "expectClosed": true,
178
+ "expectFallback": false
179
+ },
180
+ {
181
+ "id": "R29",
182
+ "task": "这几天推特上双方网友对线的来龙去脉",
183
+ "expectDomains": ["cn_social_rumor"],
184
+ "expectClosed": true
185
+ },
186
+ {
187
+ "id": "R30",
188
+ "task": "核查土耳其与某国之间的网络冲突",
189
+ "expectDomains": ["international"]
162
190
  }
163
191
  ]
164
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fact-check",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Fact-checking skill for DeepSeek Harness: verifies claims against the open web instead of model memory, cross-checks independent sources across domestic and international search ecosystems, and can route three factual judgements (does the evidence support the claim / is this source primary / does this page carry usable text) to a TypeSafe Jev (System One) decision model when a key is configured, falling back to the skill's deterministic rules when it is not.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * config — 本机配置(local.json / secrets.json)的**包外**解析。
4
+ *
5
+ * 为什么需要这一层:`dsh plugin update` / `npm i` 换掉的是整个包目录。
6
+ * 配置如果写在 `tools/local.json`,每次更新都会被清掉;这个文件只决定
7
+ * “去哪儿找配置”,不改变配置本身的格式。
8
+ *
9
+ * 解析顺序(先命中先用):
10
+ * 1. 环境变量 `JEV_LOCAL_CONFIG`(显式指定文件,便于测试与多机切换)
11
+ * 2. 平台配置目录:
12
+ * Windows %LOCALAPPDATA%\dsh-fact-check\local.json
13
+ * 其它 $XDG_CONFIG_HOME/dsh-fact-check/local.json 或 ~/.config/dsh-fact-check/local.json
14
+ * 3. 旧的包内路径 `tools/local.json`(向后兼容,不推荐继续用)
15
+ *
16
+ * `secrets.json` 用同一套目录,只是文件名不同。
17
+ * 零依赖;解析失败一律当作“没有配置”,绝不阻断核查流程。
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { join } from 'node:path'
23
+
24
+ export const APP_DIR = 'dsh-fact-check'
25
+
26
+ /** 平台配置目录——包目录之外,更新不会动它。 */
27
+ export function configDir() {
28
+ if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
29
+ return join(process.env.LOCALAPPDATA, APP_DIR)
30
+ }
31
+ if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, APP_DIR)
32
+ return join(homedir(), '.config', APP_DIR)
33
+ }
34
+
35
+ /** 用户配置文件的**目标**路径(可能还不存在)。 */
36
+ export function userConfigPath(name = 'local.json') {
37
+ return join(configDir(), name)
38
+ }
39
+
40
+ /**
41
+ * 按优先级列出候选路径。`pkgDir` 是 tools/ 目录(旧的包内位置)。
42
+ * 存在的排前面,但顺序严格按优先级——不存在的也在列表里,供 --write 判断。
43
+ */
44
+ export function configCandidates(pkgDir, name = 'local.json') {
45
+ return [process.env.JEV_LOCAL_CONFIG, userConfigPath(name), join(pkgDir, name)].filter(Boolean)
46
+ }
47
+
48
+ /** 当前实际生效的配置文件;一个都不存在时返回 null。 */
49
+ export function activeConfigPath(pkgDir, name = 'local.json') {
50
+ return configCandidates(pkgDir, name).find((f) => existsSync(f)) || null
51
+ }
52
+
53
+ /** 读 JSON;任何异常都返回 null(坏配置不能把核查流程带崩)。 */
54
+ export function readJsonSafe(file) {
55
+ try {
56
+ return JSON.parse(readFileSync(file, 'utf8'))
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 读本机配置。
64
+ * pkgDir tools/ 目录(`import.meta.url` 所在目录)
65
+ * name 文件名,默认 local.json
66
+ */
67
+ export function readLocalConfig(pkgDir, name = 'local.json') {
68
+ const file = activeConfigPath(pkgDir, name)
69
+ return file ? readJsonSafe(file) || {} : {}
70
+ }
71
+
72
+ /**
73
+ * 写配置草稿:默认写到包外用户目录,已存在则不覆盖。
74
+ * 返回 { path, written }。
75
+ */
76
+ export function writeLocalConfig(pkgDir, data, name = 'local.json') {
77
+ const existing = activeConfigPath(pkgDir, name)
78
+ if (existing) return { path: existing, written: false }
79
+ const target = userConfigPath(name)
80
+ mkdirSync(configDir(), { recursive: true })
81
+ writeFileSync(target, JSON.stringify(data, null, 2) + '\n', 'utf8')
82
+ return { path: target, written: true }
83
+ }
package/tools/doctor.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * node tools/doctor.mjs # offline checks only
10
10
  * node tools/doctor.mjs --net # also probe the endpoint, the local search service, and the fetch chain
11
- * node tools/doctor.mjs --write # write tools/local.json from detected values (won't overwrite)
11
+ * node tools/doctor.mjs --write # write the local config from detected values (won't overwrite)
12
12
  * node tools/doctor.mjs --json # machine-readable
13
13
  *
14
14
  * It never prints a secret — only where a key was found.
@@ -18,6 +18,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node
18
18
  import { dirname, join, isAbsolute, resolve } from 'node:path'
19
19
  import { fileURLToPath } from 'node:url'
20
20
  import { homedir, tmpdir } from 'node:os'
21
+ import { activeConfigPath, readLocalConfig, userConfigPath, writeLocalConfig } from './config.mjs'
21
22
  import { spawnSync } from 'node:child_process'
22
23
 
23
24
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -36,7 +37,7 @@ function readJson(file) {
36
37
  }
37
38
 
38
39
  function localConfig() {
39
- return readJson(join(HERE, 'local.json')) || {}
40
+ return readLocalConfig(HERE)
40
41
  }
41
42
 
42
43
  /* ---------------- runtime ---------------- */
@@ -44,6 +45,7 @@ function localConfig() {
44
45
  const major = Number(process.versions.node.split('.')[0])
45
46
  report.info.node = process.versions.node
46
47
  report.info.platform = `${process.platform} ${process.arch}`
48
+ report.info.configFile = activeConfigPath(HERE) || `${userConfigPath('local.json')}(尚未生成)`
47
49
  if (major >= 18) report.ok.push(`Node ${process.versions.node}(≥18,内置 fetch / AbortSignal.timeout 可用)`)
48
50
  else report.fail.push(`Node ${process.versions.node} 过旧:需要 ≥18(内置 fetch)。判据层不可用,流程仍可按 SKILL.md 人工执行`)
49
51
 
@@ -255,9 +257,9 @@ if (has('--net')) {
255
257
  /* ---------------- --write ---------------- */
256
258
 
257
259
  if (has('--write')) {
258
- const target = join(HERE, 'local.json')
259
- if (existsSync(target)) {
260
- report.warn.push(`local.json 已存在,未覆盖:${target}`)
260
+ const existing = activeConfigPath(HERE)
261
+ if (existing) {
262
+ report.warn.push(`local.json 已存在,未覆盖:${existing}`)
261
263
  } else {
262
264
  const generated = {
263
265
  _about: '由 doctor.mjs 生成;可按需修改。请勿提交或分享。',
@@ -272,8 +274,8 @@ if (has('--write')) {
272
274
  EXIFTOOL_CMD: process.env.JEV_EXIFTOOL_CMD || ov.EXIFTOOL_CMD || report.info.optionalTools.exiftool || null,
273
275
  },
274
276
  }
275
- writeFileSync(target, JSON.stringify(generated, null, 2) + '\n', 'utf8')
276
- report.ok.push(`已生成 ${target} —— 请把 null 的项按你的环境补上`)
277
+ const { path } = writeLocalConfig(HERE, generated)
278
+ report.ok.push(`已生成 ${path} —— 请把 null 的项按你的环境补上`)
277
279
  }
278
280
  }
279
281
 
@@ -287,6 +289,7 @@ if (has('--json')) {
287
289
  line('# fact-check skill · 环境自检')
288
290
  line('')
289
291
  line(`运行环境:Node ${report.info.node} · ${report.info.platform}`)
292
+ line(`本机配置:${report.info.configFile}`)
290
293
  line(`判据端点:${report.info.endpoint} · 模型:${report.info.model}`)
291
294
  line(`日志目录:${report.info.stateDir}${writable ? '(可写)' : '(不可写,会静默跳过)'}`)
292
295
  line('')
@@ -311,7 +314,7 @@ if (has('--json')) {
311
314
  '正文抽取字段探不到,请各自用一次真实抓取确认——这三条是"任一可用即可"的能力族。',
312
315
  )
313
316
  }
314
- line('下一步:cp tools/local.example.json tools/local.json 并按上面提示填写;或 node tools/doctor.mjs --write 生成草稿。')
317
+ line(`下一步:node tools/doctor.mjs --write 生成配置草稿(写到 ${userConfigPath('local.json')},包更新不会清掉);或手动创建同名文件。`)
315
318
  line('')
316
319
  }
317
320
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "_about": "渠道路由表:确定性映射,模型不参与。**这是模板,不是配置**:表里所有 ${...} 都是变量,取值由 tools/local.json 的 overrides(或 JEV_* 环境变量)提供;未填也能跑,计划会原样显示占位符并把它列进 localPlaceholdersUnset。",
2
+ "_about": "渠道路由表:确定性映射,模型不参与。**这是模板,不是配置**:表里所有 ${...} 都是变量,取值由 `local.json`(包外用户配置目录)的 overrides(或 JEV_* 环境变量)提供;未填也能跑,计划会原样显示占位符并把它列进 localPlaceholdersUnset。",
3
3
  "_template_note": "本表只描述公开可用的渠道与接口,不包含任何主机地址。下面的 capabilities 段是「这类渠道能干什么」,具体地址由你填。",
4
4
  "capabilities": {
5
5
  "searxng": "${SEARXNG_URL} —— 自托管元搜索:GET /search?q=…&format=json;引擎数量看你自己实例的 GET /config;若走 HTTP 代理,本地地址必须绕过代理;冷启首查可能超时,建议放宽到 20~30s",
@@ -31,6 +31,7 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rea
31
31
  import { dirname, join, isAbsolute, resolve } from 'node:path'
32
32
  import { fileURLToPath } from 'node:url'
33
33
  import { homedir } from 'node:os'
34
+ import { readLocalConfig, userConfigPath } from './config.mjs'
34
35
 
35
36
  const HERE = dirname(fileURLToPath(import.meta.url))
36
37
  const ENDPOINT = 'https://api.typesafe.ai/v1/systemone'
@@ -48,8 +49,7 @@ const PRICE_PER_MTOK = 0.042
48
49
  */
49
50
  export function localConfig() {
50
51
  try {
51
- const f = join(HERE, 'local.json')
52
- if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8'))
52
+ return readLocalConfig(HERE)
53
53
  } catch {
54
54
  /* a broken local.json must never break a verdict */
55
55
  }
@@ -273,6 +273,7 @@ export function resolveKey({ explicit } = {}) {
273
273
  ? process.env.JEV_KEY_FILES.split(process.platform === 'win32' ? ';' : ':')
274
274
  : []),
275
275
  join(HERE, 'secrets.json'),
276
+ userConfigPath('secrets.json'),
276
277
  ].filter(Boolean)
277
278
  for (const f of candidates) {
278
279
  try {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_about": "复制成 local.json 并填入你自己的值(或先跑 `node tools/doctor.mjs --write` 生成草稿)。local.json 属于本机配置:不要提交、不要随包分享、不要写进 SKILL.md。",
2
+ "_about": "复制成 local.json 并填入你自己的值(或先跑 `node tools/doctor.mjs --write` 生成草稿)。推荐放在包外的用户配置目录:Windows `%LOCALAPPDATA%\\dsh-fact-check\\local.json`,其它平台 `~/.config/dsh-fact-check/local.json`;也可用 JEV_LOCAL_CONFIG 指定路径。local.json 属于本机配置:不要提交、不要随包分享、不要写进 SKILL.md。",
3
3
  "_note": "全部字段都可以省略:省了就按内置默认值跑,占位符未填时 route.mjs 会把它们列进 localPlaceholdersUnset,jev-verdict.mjs 会在找不到密钥时明确报错并回退到人工规则。环境变量优先级高于本文件(JEV_ 前缀或原名,例如 JEV_SEARXNG_URL / SEARXNG_URL)。",
4
4
  "keyFile": "/path/to/your/secrets.json",
5
5
  "keyFiles": [],
package/tools/route.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  import { readFileSync, writeFileSync, existsSync } from 'node:fs'
17
17
  import { dirname, join, isAbsolute, resolve } from 'node:path'
18
18
  import { fileURLToPath } from 'node:url'
19
+ import { activeConfigPath, readLocalConfig } from './config.mjs'
19
20
 
20
21
  const HERE = dirname(fileURLToPath(import.meta.url))
21
22
  const matrix = JSON.parse(readFileSync(join(HERE, 'source-matrix.json'), 'utf8'))
@@ -28,17 +29,25 @@ const CASES_DIR = resolve(HERE, '..', 'cases')
28
29
  * never in the tables or the code. Anything environment-specific in the tables
29
30
  * is written as ${PLACEHOLDER} and resolved here from:
30
31
  * env JEV_<NAME> → env <NAME> → local.json `overrides.<NAME>` → left as-is.
32
+ *
33
+ * The file itself lives OUTSIDE the package (see `config.mjs`), so a package
34
+ * update cannot delete the machine's settings; `tools/local.json` is still read
35
+ * as a fallback for older installs.
31
36
  */
32
37
  export function localOverrides() {
33
38
  try {
34
- const f = join(HERE, 'local.json')
35
- if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).overrides || {}
39
+ return readLocalConfig(HERE).overrides || {}
36
40
  } catch {
37
41
  /* a broken local.json must not break planning */
38
42
  }
39
43
  return {}
40
44
  }
41
45
 
46
+ /** Which local.json is actually in effect — surfaced by the doctor/plan output. */
47
+ export function localConfigPath() {
48
+ return activeConfigPath(HERE)
49
+ }
50
+
42
51
  export function subst(text) {
43
52
  const ov = localOverrides()
44
53
  return String(text).replace(/\$\{([A-Z0-9_]+)\}/g, (m, name) =>
@@ -86,8 +95,8 @@ const DOMAIN_SIGNALS = [
86
95
  ['entertainment_celebrity', /(明星|艺人|演员|歌手|导演|主持人|网红|名人|公众人物|工作室|经纪公司|娱乐公司|粉丝|饭圈|塌房|绯闻|恋情|出道|退圈|封杀|演唱会|影视剧|综艺)/i],
87
96
  ['cn_policy', /(政策|法规|条例|法条|司法解释|统计年鉴|文号|国务院|征求意见)/i],
88
97
  ['finance', /(上市|股价|财报|融资|重组|证券|交易所|IPO|币|加密货币|跑路)/i],
89
- ['cn_social_rumor', /(传闻|谣言|辟谣|网传|社媒|公众号|知乎|B\s?站|微博|小红书|抖音|舆情|热搜|大V|博主|网暴|舆论危机)/i],
90
- ['international', /(国际|境外|外国|跨国|海外|联合国|世卫|欧盟|美国|美方|日本|日方|欧洲|白宫|国会|当地)/i],
98
+ ['cn_social_rumor', /(传闻|谣言|辟谣|网传|社媒|公众号|知乎|B\s?站|微博|小红书|抖音|舆情|热搜|大V|博主|网暴|舆论危机|推特|Twitter|X\s*平台|骂战|对线|论战|口水战|开撕|网络战)/i],
99
+ ['international', /(国际|境外|外国|跨国|海外|联合国|世卫|欧盟|美国|美方|日本|日方|欧洲|白宫|国会|当地|土耳其|希腊|俄罗斯|越南|印度|韩国|朝鲜|以色列|巴勒斯坦|乌克兰|阿根廷|巴西|墨西哥|沙特|埃及)/i],
91
100
  ['company_claim', /(公司|企业|厂商|宣布|合作|行业第一|市占|官网)/i],
92
101
  ['tech_release', /(版本|更新|发布|仓库|插件|npm|pip|库|许可|LICENSE|CVE|RFC|API|模型|部署)/i],
93
102
  ]
@@ -115,14 +124,20 @@ const DOMAIN_ROUTES = {
115
124
  education_exam: ['gov_notice', 'general_web', 'news_rss'],
116
125
  sports: ['sports_official', 'general_web', 'closed_intl_social'],
117
126
  entertainment_celebrity: ['entertainment_official', 'closed_cn_social', 'archive'],
127
+ general_fact: ['general_web', 'searxng_meta', 'news_rss', 'archive'],
118
128
  }
119
129
 
120
130
  export function planFor(task) {
121
131
  const matched = []
122
132
  for (const [domain, re] of DOMAIN_SIGNALS) if (re.test(task)) matched.push(domain)
123
- // An unmatched task falls back to technology releases: the widest ladder, and the
124
- // only one whose routes (general search + metasearch + vertical) fit any topic.
125
- const chosen = matched.length ? matched : ['tech_release']
133
+ // An unmatched task falls back to the general fact-check domain: a ladder that
134
+ // applies to any topic (party's own release → wire service → metasearch → archive
135
+ // → local media), not the software-release ladder. Falling back to tech_release
136
+ // handed unrelated tasks a README/CHANGELOG ladder, and because that domain is
137
+ // not closed, a hint saying a single route was enough — which talked the agent
138
+ // out of dispatching.
139
+ const fallback = matched.length === 0
140
+ const chosen = fallback ? ['general_fact'] : matched
126
141
  const ladders = chosen.map((d) => ({ domain: d, label: matrix.domains[d].label, ladder: matrix.domains[d].ladder }))
127
142
  const routeIds = [...new Set(chosen.flatMap((d) => DOMAIN_ROUTES[d] || []))]
128
143
  const chosenRoutes = routeIds.map((id) => routes.routes.find((r) => r.id === id)).filter(Boolean)
@@ -131,15 +146,20 @@ export function planFor(task) {
131
146
  return {
132
147
  task,
133
148
  domains: chosen,
149
+ // True when no domain matched and the general ladder was used: read that ladder
150
+ // as a starting point, not as a domain verdict.
151
+ fallback,
134
152
  closedEcosystem: closed,
135
153
  factPointsInText: factPoints,
136
154
  ladders: substDeep(ladders),
137
155
  routes: chosenRoutes.map((r) => ({ id: r.id, when: subst(r.when), primary: substDeep(r.primary), notes: subst(r.notes) })),
138
156
  failureActions: substDeep(routes._failure_actions),
139
157
  localPlaceholdersUnset: unresolved(),
140
- dispatchHint: closed || chosen.length >= 3 || factPoints >= 4
141
- ? '需要多路取证(封闭生态 / 多类型事实点)→ 候选派子代理;最终仍以派发判据标注集为准'
142
- : '单路可覆盖,串行处理即可',
158
+ dispatchHint: fallback
159
+ ? '未识别事实类型(兜底域)→ 按 SKILL.md 的信号表判断:涉及封闭生态 / 需抓大量长文 / 事实点 ≥4,任一命中即派子代理'
160
+ : closed || chosen.length >= 3 || factPoints >= 4
161
+ ? '需要多路取证(封闭生态 / 多类型事实点)→ 候选派子代理;最终仍以派发判据标注集为准'
162
+ : '单路可覆盖,串行处理即可',
143
163
  }
144
164
  }
145
165
 
@@ -248,7 +268,23 @@ function selftest() {
248
268
  const s = JSON.stringify(routes._failure_actions)
249
269
  return s.includes('403') && (s.includes('超时') || s.includes('000'))
250
270
  })
251
- t('未匹配任务有兜底域', () => planFor('???').domains[0] === 'tech_release')
271
+ t('未匹配任务有兜底域', () => planFor('???').domains[0] === 'general_fact')
272
+ t('兜底域不是软件发布域(那套阶梯与多数任务无关)', () => planFor('???').domains[0] !== 'tech_release')
273
+ t('兜底域给出通用来源阶梯', () => {
274
+ const l = planFor('今天天气不错').ladders[0].ladder.join('|')
275
+ return l.includes('通讯社') && l.includes('当事方')
276
+ })
277
+ t('兜底任务提示按信号表判断派发(不误劝退)', () => {
278
+ const h = planFor('???').dispatchHint
279
+ return h.includes('未识别事实类型') && h.includes('派子代理')
280
+ })
281
+ t('兜底任务显式标记 fallback', () => planFor('???').fallback === true && planFor('网传某地发生地震').fallback === false)
282
+ t('社交平台战类任务识别出封闭生态', () =>
283
+ planFor('确认这几天 X 平台上两国网民骂战的来龙去脉和目前的状况').closedEcosystem === true)
284
+ t('社交平台战类任务识别为社媒域', () =>
285
+ planFor('这几天推特上双方网友对线的来龙去脉').domains.includes('cn_social_rumor'))
286
+ t('常见外方国名识别为国际域', () =>
287
+ planFor('核查土耳其与某国之间的网络冲突').domains.includes('international'))
252
288
  t('常见任务的路线数不超过 5 条', () => planFor('核查某公司宣称的合作是否属实,需要境外监管文件').routes.length <= 5)
253
289
  t('可移植:占位符可由 JEV_* 环境变量解析', () => {
254
290
  const old = process.env.JEV_SEARXNG_URL
@@ -335,6 +371,7 @@ function regress(file, out) {
335
371
  if (c.expectDomains) checks.push(['expectDomains', c.expectDomains.every((d) => p.domains.includes(d))])
336
372
  if (c.expectNotDomains) checks.push(['expectNotDomains', c.expectNotDomains.every((d) => !p.domains.includes(d))])
337
373
  if (typeof c.expectClosed === 'boolean') checks.push(['expectClosed', p.closedEcosystem === c.expectClosed])
374
+ if (typeof c.expectFallback === 'boolean') checks.push(['expectFallback', p.fallback === c.expectFallback])
338
375
  if (c.expectRoutes) checks.push(['expectRoutes', c.expectRoutes.every((r) => p.routes.some((x) => x.id === r))])
339
376
  if (typeof c.expectDispatch === 'boolean') {
340
377
  checks.push(['expectDispatch', c.expectDispatch === (p.dispatchHint.startsWith('需要多路取证'))])
@@ -346,6 +383,7 @@ function regress(file, out) {
346
383
  passed: ok,
347
384
  failed: failedOn,
348
385
  domains: p.domains,
386
+ fallback: p.fallback,
349
387
  closedEcosystem: p.closedEcosystem,
350
388
  routes: p.routes.map((r) => r.id),
351
389
  })
@@ -106,6 +106,13 @@
106
106
  "closed": true,
107
107
  "note": "工作室声明是当事人立场而非中立证据:写进报告时须标注为「当事人声明」,不可当作第三方证据",
108
108
  "languages": ["zh", "en"]
109
+ },
110
+ "general_fact": {
111
+ "label": "通用事实核查(事实类型未识别时的兜底域)",
112
+ "ladder": ["当事方或主管机构的原始发布(官网、公告、原始账号)", "通讯社与官方广播(中外各一组)", "自托管元搜索与批量搜索交叉", "存档快照(Wayback)核对页面是否存在、是否被改", "当事国本地媒体(当地语言原文)", "社区与个人讨论(仅线索)"],
113
+ "closed": false,
114
+ "note": "兜底域:任务文本匹配不到任何具体事实类别时使用。早期版本兜底到 tech_release,那条阶梯是「官方仓库 / 包管理器元数据」,与多数任务无关,会同时给出错误的来源阶梯与错误的派发建议(该域 closed=false,提示会说「单路可覆盖」)。closed=false 只表示「尚未识别出封闭生态」,不表示任务一定不涉及——派发与否仍按 SKILL.md 的信号表判断。",
115
+ "languages": ["zh", "en"]
109
116
  }
110
117
  },
111
118
  "_rules": [