dsh-fact-check 1.3.0

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.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * make-instructions — generate INSTRUCTIONS.md (same body, no frontmatter) from
4
+ * SKILL.md, for agent environments that take plain instructions (AGENTS.md and
5
+ * friends) instead of a skill loader.
6
+ *
7
+ * Usage: node tools/make-instructions.mjs
8
+ */
9
+
10
+ import { readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url))
15
+ const ROOT = join(HERE, '..')
16
+ const skill = readFileSync(join(ROOT, 'SKILL.md'), 'utf8')
17
+
18
+ // strip a leading YAML frontmatter block if present
19
+ const body = skill.replace(/^---\n[\s\S]*?\n---\n/, '').trimStart()
20
+ const name = (skill.match(/^name:\s*(.+)$/m) || [, 'fact-check'])[1].trim()
21
+ const description = (skill.match(/^description:\s*(.+)$/m) || [, ''])[1].trim()
22
+
23
+ const out = `<!-- 由 tools/make-instructions.mjs 从 SKILL.md 生成,请勿手改:改 SKILL.md 后重跑本脚本。 -->
24
+ # ${name}
25
+
26
+ ${description ? `> ${description}\n\n` : ''}${body}
27
+ `
28
+
29
+ writeFileSync(join(ROOT, 'INSTRUCTIONS.md'), out, 'utf8')
30
+ console.log(`已生成 INSTRUCTIONS.md(${out.split('\n').length} 行,来源 SKILL.md)`)
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * route — deterministic planner for the fact-check skill.
4
+ *
5
+ * Given a one-line task description it prints WHERE to look (source ladder) and
6
+ * HOW to look (engines / channels), plus the failure-action table. No model is
7
+ * involved: this is the part of the pipeline that must never cost tokens or
8
+ * drift, and it is auditable line by line.
9
+ *
10
+ * Usage:
11
+ * node tools/route.mjs plan --task "核实某地自来水超标传闻"
12
+ * node tools/route.mjs selftest
13
+ * node tools/route.mjs arms
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
17
+ import { dirname, join, isAbsolute, resolve } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url))
21
+ const matrix = JSON.parse(readFileSync(join(HERE, 'source-matrix.json'), 'utf8'))
22
+ const routes = JSON.parse(readFileSync(join(HERE, 'engine-routes.json'), 'utf8'))
23
+ /** The bundled corpora live beside tools/ — located from this file, never hard-coded. */
24
+ const CASES_DIR = resolve(HERE, '..', 'cases')
25
+
26
+ /**
27
+ * Machine-local values live in `local.json` (shipped as local.example.json) —
28
+ * never in the tables or the code. Anything environment-specific in the tables
29
+ * is written as ${PLACEHOLDER} and resolved here from:
30
+ * env JEV_<NAME> → env <NAME> → local.json `overrides.<NAME>` → left as-is.
31
+ */
32
+ export function localOverrides() {
33
+ try {
34
+ const f = join(HERE, 'local.json')
35
+ if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8')).overrides || {}
36
+ } catch {
37
+ /* a broken local.json must not break planning */
38
+ }
39
+ return {}
40
+ }
41
+
42
+ export function subst(text) {
43
+ const ov = localOverrides()
44
+ return String(text).replace(/\$\{([A-Z0-9_]+)\}/g, (m, name) =>
45
+ process.env[`JEV_${name}`] || process.env[name] || ov[name] || m)
46
+ }
47
+
48
+ function substDeep(value) {
49
+ if (typeof value === 'string') return subst(value)
50
+ if (Array.isArray(value)) return value.map(substDeep)
51
+ if (value && typeof value === 'object') {
52
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substDeep(v)]))
53
+ }
54
+ return value
55
+ }
56
+
57
+ /** Placeholders with no value anywhere — the caller must fill them in. */
58
+ export function unresolved() {
59
+ const found = new Set()
60
+ const walk = (v) => {
61
+ if (typeof v === 'string') {
62
+ for (const m of v.matchAll(/\$\{([A-Z0-9_]+)\}/g)) found.add(m[1])
63
+ } else if (Array.isArray(v)) v.forEach(walk)
64
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk)
65
+ }
66
+ walk(routes)
67
+ return [...found].filter((name) => subst(`\${${name}}`) === `\${${name}}`)
68
+ }
69
+
70
+ /**
71
+ * Keyword → domain. The order is "most specific first" for reading only: every
72
+ * regex that matches is kept and the domains are unioned, so one task can land in
73
+ * several at once — a rumour about an earthquake is both `disaster_emergency`
74
+ * and `cn_social_rumor`, and both ladders are then handed over.
75
+ */
76
+ const DOMAIN_SIGNALS = [
77
+ ['image_video', /(图片|照片|视频|监控|截图|EXIF|合成|伪造的?图|deepfake)/i],
78
+ ['legal_case', /(判决|判刑|量刑|起诉|批捕|逮捕|刑拘|刑事拘留|立案|开庭|庭审|宣判|法院|检察院|警情通报|警方通报|纪委监委|取保候审|缓刑|无期徒刑|死刑|案件|嫌疑人|被告人)/i],
79
+ ['disaster_emergency', /(地震|震级|余震|台风|飓风|洪水|汛情|暴雨|山洪|泥石流|滑坡|干旱|雪灾|冰雹|森林火灾|爆炸|坍塌|垮塌|溃坝|堰塞湖|泄洪|矿难|坠机|失事|沉船|踩踏|车祸|事故|伤亡|遇难|失联|被困|救援|灾害|灾情)/i],
80
+ ['food_safety', /(食品|添加剂|防腐剂|保鲜剂|色素|甜味剂|农残|农药残留|兽药|抗生素|激素|保质期|致癌|重金属|地沟油|预制菜|转基因|反式脂肪|奶粉|奶制品|生鲜|食安)/i],
81
+ ['medical', /(药|保健品|治疗|临床|疫苗|医疗|病|疗效|药监)/i],
82
+ ['science_general', /(科学|科普|伪科学|辐射|电磁|基因|进化|量子|宇宙|黑洞|太阳|月球|磁场|化学|物理|天文|地质|气候|病毒|细菌|DNA|养生|偏方|排毒|酸碱体质)/i],
83
+ ['academic', /(论文|期刊|研究|学术|DOI|撤稿|预印本|被引)/i],
84
+ ['education_exam', /(高考|中考|考研|招生|录取|学信网|毕业证|学位|分数线|考试|教师|教育局|教育厅|教育部|双减|学区|教材)/i],
85
+ ['sports', /(比赛|赛事|联赛|世界杯|奥运|锦标赛|冠军|夺冠|金牌|进球|比分|战绩|转会|签约|球员|球队|俱乐部|运动员|教练|裁判|兴奋剂|禁赛|退役|纪录)/i],
86
+ ['entertainment_celebrity', /(明星|艺人|演员|歌手|导演|主持人|网红|名人|公众人物|工作室|经纪公司|娱乐公司|粉丝|饭圈|塌房|绯闻|恋情|出道|退圈|封杀|演唱会|影视剧|综艺)/i],
87
+ ['cn_policy', /(政策|法规|条例|法条|司法解释|统计年鉴|文号|国务院|征求意见)/i],
88
+ ['finance', /(上市|股价|财报|融资|重组|证券|交易所|IPO|币|加密货币|跑路)/i],
89
+ ['cn_social_rumor', /(传闻|谣言|辟谣|网传|社媒|公众号|知乎|B\s?站|微博|小红书|抖音|舆情|热搜|大V|博主|网暴|舆论危机)/i],
90
+ ['international', /(国际|境外|外国|跨国|海外|联合国|世卫|欧盟|美国|美方|日本|日方|欧洲|白宫|国会|当地)/i],
91
+ ['company_claim', /(公司|企业|厂商|宣布|合作|行业第一|市占|官网)/i],
92
+ ['tech_release', /(版本|更新|发布|仓库|插件|npm|pip|库|许可|LICENSE|CVE|RFC|API|模型|部署)/i],
93
+ ]
94
+
95
+ /**
96
+ * domain → engine route ids (from engine-routes.json).
97
+ * Every domain stays at or below five routes (selftest asserts it). A plan that
98
+ * matches several domains unions their routes, so a multi-domain task legitimately
99
+ * sees more than five.
100
+ */
101
+ const DOMAIN_ROUTES = {
102
+ cn_policy: ['official_json_api', 'general_web', 'news_rss'],
103
+ cn_social_rumor: ['searxng_meta', 'closed_cn_social', 'general_web', 'factcheck_registry'],
104
+ tech_release: ['general_web', 'searxng_meta', 'vertical_domain'],
105
+ academic: ['academic_api', 'general_web', 'vertical_domain'],
106
+ medical: ['gov_notice', 'academic_api', 'general_web'],
107
+ finance: ['official_json_api', 'general_web', 'vertical_domain', 'news_rss'],
108
+ international: ['general_web', 'searxng_meta', 'news_rss', 'archive'],
109
+ image_video: ['media_metadata', 'closed_cn_social', 'closed_intl_social', 'archive'],
110
+ company_claim: ['general_web', 'domain_intel', 'archive', 'news_rss'],
111
+ legal_case: ['gov_notice', 'general_web', 'news_rss', 'archive'],
112
+ disaster_emergency: ['gov_notice', 'news_rss', 'general_web', 'archive'],
113
+ food_safety: ['gov_notice', 'general_web', 'vertical_domain'],
114
+ science_general: ['science_authority', 'factcheck_registry', 'general_web'],
115
+ education_exam: ['gov_notice', 'general_web', 'news_rss'],
116
+ sports: ['sports_official', 'general_web', 'closed_intl_social'],
117
+ entertainment_celebrity: ['entertainment_official', 'closed_cn_social', 'archive'],
118
+ }
119
+
120
+ export function planFor(task) {
121
+ const matched = []
122
+ 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']
126
+ const ladders = chosen.map((d) => ({ domain: d, label: matrix.domains[d].label, ladder: matrix.domains[d].ladder }))
127
+ const routeIds = [...new Set(chosen.flatMap((d) => DOMAIN_ROUTES[d] || []))]
128
+ const chosenRoutes = routeIds.map((id) => routes.routes.find((r) => r.id === id)).filter(Boolean)
129
+ const closed = chosen.some((d) => matrix.domains[d].closed)
130
+ const factPoints = [...task.matchAll(/[;;。\n]/g)].length + 1
131
+ return {
132
+ task,
133
+ domains: chosen,
134
+ closedEcosystem: closed,
135
+ factPointsInText: factPoints,
136
+ ladders: substDeep(ladders),
137
+ routes: chosenRoutes.map((r) => ({ id: r.id, when: subst(r.when), primary: substDeep(r.primary), notes: subst(r.notes) })),
138
+ failureActions: substDeep(routes._failure_actions),
139
+ localPlaceholdersUnset: unresolved(),
140
+ dispatchHint: closed || chosen.length >= 3 || factPoints >= 4
141
+ ? '需要多路取证(封闭生态 / 多类型事实点)→ 候选派子代理;最终仍以派发判据标注集为准'
142
+ : '单路可覆盖,串行处理即可',
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Proxy metric for the dispatch arms table in MEASUREMENTS.md §4: take this
148
+ * planner's own `dispatchHint` as the deterministic-keyword arm and score it
149
+ * against the consensus of two independent blind labellers. Only the samples both
150
+ * labellers agree on are counted — the disputed ones are ambiguous by construction
151
+ * and say nothing about the rule.
152
+ *
153
+ * ⚠ This is NOT the R rule of §4: that one is author-side and not shipped, so this
154
+ * number must never be read against §4's 88.6%.
155
+ */
156
+ export function armAgreement(tasks, labelsA, labelsB) {
157
+ const a = new Map(labelsA.map((x) => [x.id, x.label]))
158
+ const b = new Map(labelsB.map((x) => [x.id, x.label]))
159
+ const out = { agree: 0, total: 0, pct: 0, falseDispatch: 0, missedDispatch: 0, mismatches: [] }
160
+ for (const c of tasks) {
161
+ const la = a.get(c.id)
162
+ const lb = b.get(c.id)
163
+ if (la === undefined || lb === undefined || la !== lb) continue
164
+ const plan = planFor(c.task)
165
+ const rule = plan.dispatchHint.startsWith('需要多路取证')
166
+ out.total++
167
+ if (rule === la) {
168
+ out.agree++
169
+ continue
170
+ }
171
+ if (rule) out.falseDispatch++
172
+ else out.missedDispatch++
173
+ out.mismatches.push({
174
+ id: c.id,
175
+ consensus: la,
176
+ rule,
177
+ closedEcosystem: plan.closedEcosystem,
178
+ domains: plan.domains,
179
+ task: c.task,
180
+ })
181
+ }
182
+ out.pct = out.total ? Number(((out.agree / out.total) * 100).toFixed(1)) : 0
183
+ return out
184
+ }
185
+
186
+ /** Load the bundled dispatch corpus plus both labellings, print the proxy metric, optionally write it out. */
187
+ function arms(opts) {
188
+ const load = (p) => JSON.parse(readFileSync(p, 'utf8'))
189
+ const files = {
190
+ blind: opts.file || join(CASES_DIR, 'dispatch-blind-neutral.json'),
191
+ l1: opts.l1 || join(CASES_DIR, 'dispatch-label-L1-neutral.json'),
192
+ l2: opts.l2 || join(CASES_DIR, 'dispatch-label-L2-neutral.json'),
193
+ }
194
+ for (const p of Object.values(files)) {
195
+ if (!existsSync(p)) {
196
+ console.error(`读不到语料:${p}`)
197
+ console.error(
198
+ '用法: node tools/route.mjs arms [--file <blind.json>] [--l1 <l1.json>] [--l2 <l2.json>] [--out <report.json>]',
199
+ )
200
+ return 1
201
+ }
202
+ }
203
+ const blind = load(files.blind)
204
+ const result = armAgreement(blind.cases ?? blind, load(files.l1).labels, load(files.l2).labels)
205
+ console.log(
206
+ `R 规则臂(代理指标 = 本工具的 dispatchHint)与双盲共识一致:${result.agree}/${result.total} = ${result.pct.toFixed(1)}%`,
207
+ )
208
+ console.log(` 误判派=${result.falseDispatch} 漏判派=${result.missedDispatch}`)
209
+ console.log(' ⚠ 这是代理指标,不是 MEASUREMENTS.md §4 里作者侧的 R 规则(88.6%)——两者实现不同,不可横向比较。')
210
+ for (const m of result.mismatches) {
211
+ console.log(
212
+ ` ${m.id} 共识=${m.consensus} 规则=${m.rule} closed=${m.closedEcosystem}` +
213
+ ` 域数=${m.domains.length}(${m.domains.join('/')}) | ${m.task.slice(0, 46)}`,
214
+ )
215
+ }
216
+ if (opts.out) {
217
+ writeFileSync(opts.out, JSON.stringify({ metric: 'dispatchHint-as-keyword-arm', ...result }, null, 1))
218
+ console.log(` 逐例结果已写入 ${opts.out}`)
219
+ }
220
+ return 0
221
+ }
222
+
223
+ function selftest() {
224
+ const checks = []
225
+ const t = (name, fn) => {
226
+ try {
227
+ checks.push([name, fn() ? 'pass' : 'FAIL'])
228
+ } catch (e) {
229
+ checks.push([name, `FAIL (${e.message})`])
230
+ }
231
+ }
232
+ t('每次规划都至少落一个域', () => planFor('核实某说法').domains.length >= 1)
233
+ t('谣传类任务识别出封闭生态', () => planFor('核查一条微信公众号首发的传闻').closedEcosystem === true)
234
+ t('谣言类走到辟谣平台', () => planFor('核实一条网传谣言').routes.some((r) => r.id === 'factcheck_registry'))
235
+ t('学术任务走 Crossref', () => planFor('核查这篇论文是否被撤稿').routes.some((r) => r.id === 'academic_api'))
236
+ t('政策任务走官方 JSON 接口', () => planFor('确认该政策文件的文号').routes.some((r) => r.id === 'official_json_api'))
237
+ t('视频任务走 EXIF 通道', () => planFor('核实这段视频的真实性').routes.some((r) => r.id === 'media_metadata'))
238
+ t('图片任务识别为封闭生态', () => planFor('这张照片是真的吗').closedEcosystem === true)
239
+ t('舆情类任务识别为社媒域(事件性质词,不只平台名)', () =>
240
+ planFor('核查某公司近期舆情事件的始末').domains.includes('cn_social_rumor'))
241
+ t('舆情类任务触发多路取证', () => planFor('核查某公司近期舆情事件的始末').closedEcosystem === true)
242
+ t('热搜类任务识别为社媒域', () => planFor('某品牌冲上热搜,核实那条说法').domains.includes('cn_social_rumor'))
243
+ t('不以单字误命中国际域', () => !planFor('这套方案的设计很美观').domains.includes('international'))
244
+ t('每个域的 ladder 都非空', () => Object.values(matrix.domains).every((d) => Array.isArray(d.ladder) && d.ladder.length >= 3))
245
+ t('每张域路由表引用的 route 都真实存在', () =>
246
+ Object.entries(DOMAIN_ROUTES).every(([d, ids]) => matrix.domains[d] && ids.every((id) => routes.routes.some((r) => r.id === id))))
247
+ t('失败动作表覆盖超时与拒绝两类', () => {
248
+ const s = JSON.stringify(routes._failure_actions)
249
+ return s.includes('403') && (s.includes('超时') || s.includes('000'))
250
+ })
251
+ t('未匹配任务有兜底域', () => planFor('???').domains[0] === 'tech_release')
252
+ t('常见任务的路线数不超过 5 条', () => planFor('核查某公司宣称的合作是否属实,需要境外监管文件').routes.length <= 5)
253
+ t('可移植:占位符可由 JEV_* 环境变量解析', () => {
254
+ const old = process.env.JEV_SEARXNG_URL
255
+ process.env.JEV_SEARXNG_URL = 'http://127.0.0.1:9999'
256
+ const ok = subst('${SEARXNG_URL}') === 'http://127.0.0.1:9999'
257
+ if (old === undefined) delete process.env.JEV_SEARXNG_URL
258
+ else process.env.JEV_SEARXNG_URL = old
259
+ return ok
260
+ })
261
+ t('可移植:未赋值的占位符会被显式列出', () => Array.isArray(unresolved()) && planFor('核实一下').localPlaceholdersUnset.length === unresolved().length)
262
+ t('可移植:代码里不硬编码本机路径', () => {
263
+ const self = readFileSync(fileURLToPath(import.meta.url), 'utf8')
264
+ return !/\/mnt\/[a-z]\//.test(self) && !/\.dsh\//.test(self)
265
+ })
266
+ t('每个域的路线数不超过 5 条', () => Object.values(DOMAIN_ROUTES).every((ids) => ids.length <= 5))
267
+ t('来源阶梯表里每个域都有渠道路由映射', () =>
268
+ Object.keys(matrix.domains).every((d) => Array.isArray(DOMAIN_ROUTES[d]) && DOMAIN_ROUTES[d].length > 0))
269
+ t('灾害类任务走官方通报表', () => planFor('网传某地发生地震,伤亡人数被夸大').routes.some((r) => r.id === 'gov_notice'))
270
+ t('食品安全类任务走官方通报表', () => planFor('网传这种蔬菜农药残留超标').routes.some((r) => r.id === 'gov_notice'))
271
+ t('科学常识类任务走科学辟谣源', () =>
272
+ planFor('太阳耀斑会让人心情变差吗').routes.some((r) => r.id === 'science_authority'))
273
+ t('教育考试类任务走官方通报表', () => planFor('网传明年高考取消英语科目').routes.some((r) => r.id === 'gov_notice'))
274
+ t('体育类任务走体育官方源', () => planFor('核实某球员的转会传闻').routes.some((r) => r.id === 'sports_official'))
275
+ t('娱乐类任务走娱乐官方声明源', () =>
276
+ planFor('核实某明星去世的网传消息').routes.some((r) => r.id === 'entertainment_official'))
277
+ t('司法类任务走官方通报表', () => planFor('网传某案一审宣判结果').routes.some((r) => r.id === 'gov_notice'))
278
+ t('死亡数字不误归娱乐域', () => !planFor('某地地震遇难人数').domains.includes('entertainment_celebrity'))
279
+ t('科学常识不误归学术域', () => !planFor('太阳耀斑会让人心情变差吗').domains.includes('academic'))
280
+ t('案件判决不误归政策域', () => !planFor('某地一起刑事案件的判决书').domains.includes('cn_policy'))
281
+ t('用药类任务走官方通报(药监/市监)', () =>
282
+ planFor('这个保健品宣称的疗效有临床证据吗').routes.some((r) => r.id === 'gov_notice'))
283
+ t('司法案件的阶梯把裁判文书网放在末位(需登录、公开率已大幅下降)', () => {
284
+ const ladder = matrix.domains.legal_case.ladder
285
+ const last = ladder[ladder.length - 1]
286
+ return /裁判文书网/.test(last) && /注册登录/.test(last)
287
+ })
288
+ t('失败动作表覆盖「数字滚动修正」这类伪矛盾', () => {
289
+ const s = JSON.stringify(routes._failure_actions)
290
+ return s.includes('初报') && s.includes('滚动')
291
+ })
292
+ // 派发臂代理指标:用构造的小样本测纯逻辑,不依赖 cases/ 目录是否存在
293
+ t('代理指标只计入两位标注员的共识样本', () => {
294
+ const tasks = [
295
+ { id: 'X1', task: '核实某说法' },
296
+ { id: 'X2', task: '核实某地地震伤亡' },
297
+ ]
298
+ const a = [
299
+ { id: 'X1', label: false },
300
+ { id: 'X2', label: true },
301
+ ]
302
+ const b = [
303
+ { id: 'X1', label: true },
304
+ { id: 'X2', label: true },
305
+ ]
306
+ const r = armAgreement(tasks, a, b)
307
+ return r.total === 1 && r.agree === 0 && r.missedDispatch === 1
308
+ })
309
+ t('代理指标在无共识样本时不会除以零', () => {
310
+ const r = armAgreement([{ id: 'X1', task: '核实某说法' }], [{ id: 'X1', label: true }], [{ id: 'X1', label: false }])
311
+ return r.total === 0 && r.pct === 0
312
+ })
313
+ t('代理指标与规则同向时判为一致', () => {
314
+ const r = armAgreement([{ id: 'X1', task: '核实某地地震伤亡' }], [{ id: 'X1', label: false }], [{ id: 'X1', label: false }])
315
+ return r.total === 1 && r.agree === 1 && r.mismatches.length === 0
316
+ })
317
+
318
+ const failed = checks.filter(([, s]) => s !== 'pass')
319
+ for (const [n, s] of checks) console.log(` ${s === 'pass' ? '✓' : '✗'} ${n}${s === 'pass' ? '' : ' — ' + s}`)
320
+ console.log(`\n${checks.length - failed.length}/${checks.length} 通过`)
321
+ return failed.length === 0
322
+ }
323
+
324
+ /** 路由语料回归:任务描述 → 期望命中/不命中的域与路线。不出网、不调模型、0 成本。`--out` 落盘逐例结果。 */
325
+ function regress(file, out) {
326
+ const path = isAbsolute(file) ? file : resolve(process.cwd(), file)
327
+ const data = JSON.parse(readFileSync(path, 'utf8'))
328
+ const cases = data.cases ?? data
329
+ const fails = []
330
+ const rows = []
331
+ let pass = 0
332
+ for (const c of cases) {
333
+ const p = planFor(c.task)
334
+ const checks = []
335
+ if (c.expectDomains) checks.push(['expectDomains', c.expectDomains.every((d) => p.domains.includes(d))])
336
+ if (c.expectNotDomains) checks.push(['expectNotDomains', c.expectNotDomains.every((d) => !p.domains.includes(d))])
337
+ if (typeof c.expectClosed === 'boolean') checks.push(['expectClosed', p.closedEcosystem === c.expectClosed])
338
+ if (c.expectRoutes) checks.push(['expectRoutes', c.expectRoutes.every((r) => p.routes.some((x) => x.id === r))])
339
+ if (typeof c.expectDispatch === 'boolean') {
340
+ checks.push(['expectDispatch', c.expectDispatch === (p.dispatchHint.startsWith('需要多路取证'))])
341
+ }
342
+ const failedOn = checks.filter(([, v]) => !v).map(([n]) => n)
343
+ const ok = checks.length > 0 && failedOn.length === 0
344
+ rows.push({
345
+ id: c.id,
346
+ passed: ok,
347
+ failed: failedOn,
348
+ domains: p.domains,
349
+ closedEcosystem: p.closedEcosystem,
350
+ routes: p.routes.map((r) => r.id),
351
+ })
352
+ if (ok) {
353
+ pass++
354
+ continue
355
+ }
356
+ if (!checks.length) {
357
+ fails.push(`${c.id}: 语料里没有任何 expect* 断言`)
358
+ continue
359
+ }
360
+ fails.push(
361
+ `${c.id}: ${failedOn.join(',')} 不达标` +
362
+ ` → 实际 domains=${p.domains.join('/')} closed=${p.closedEcosystem} routes=${p.routes.map((r) => r.id).join('/')}`,
363
+ )
364
+ }
365
+ console.log(`\nroute 语料 ${pass}/${cases.length} 通过`)
366
+ for (const f of fails) console.log(` ✗ ${f}`)
367
+ if (out) {
368
+ writeFileSync(out, JSON.stringify({ corpus: path, pass, total: cases.length, cases: rows }, null, 1))
369
+ console.log(` 逐例结果已写入 ${out}`)
370
+ }
371
+ return fails.length === 0
372
+ }
373
+
374
+ function main() {
375
+ const cmd = process.argv[2]
376
+ if (cmd === 'selftest') process.exit(selftest() ? 0 : 1)
377
+ if (cmd === 'regress') {
378
+ const i = process.argv.indexOf('--file')
379
+ if (i < 0 || !process.argv[i + 1]) {
380
+ console.error('用法: node tools/route.mjs regress --file cases/route-cases-neutral.json [--out report.json]')
381
+ process.exit(1)
382
+ }
383
+ const o = process.argv.indexOf('--out')
384
+ process.exit(regress(process.argv[i + 1], o >= 0 ? process.argv[o + 1] : undefined) ? 0 : 1)
385
+ }
386
+ if (cmd === 'plan') {
387
+ const i = process.argv.indexOf('--task')
388
+ const task = i >= 0 ? process.argv[i + 1] : ''
389
+ if (!task) {
390
+ console.error('用法: node tools/route.mjs plan --task "..."')
391
+ process.exit(1)
392
+ }
393
+ const plan = planFor(task)
394
+ console.log(JSON.stringify(plan, null, 1))
395
+ return
396
+ }
397
+ if (cmd === 'arms') {
398
+ const arg = (name) => {
399
+ const i = process.argv.indexOf(name)
400
+ return i >= 0 ? process.argv[i + 1] : undefined
401
+ }
402
+ process.exit(arms({ file: arg('--file'), l1: arg('--l1'), l2: arg('--l2'), out: arg('--out') }))
403
+ }
404
+ console.log(
405
+ '用法: node tools/route.mjs <plan --task "…" | selftest | regress --file <cases.json> | arms [--out <report.json>]>',
406
+ )
407
+ }
408
+
409
+ const direct = process.argv[1] && process.argv[1].endsWith('route.mjs')
410
+ if (direct) main()
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * smoke-plugin — 离线自检:本包作为 DSH 插件是否装得上、挂得对。
4
+ *
5
+ * 为什么需要它:DSH 这边最坏的失效形态是**插件一声不响地没挂上** —— 没有报错、
6
+ * 命令照跑。所以这里不看「有没有抛错」,而是看**副作用**:用一个假的 ctx 调 `apply()`,
7
+ * 断言它确实注册了 provider,再断言 `list()`/`get()` 交出的东西是对的,
8
+ * 尤其是「候选描述 = SKILL.md 的 frontmatter」这条单一真相源。
9
+ *
10
+ * 全程离线、零依赖、不碰 DSH、不碰本机配置。
11
+ *
12
+ * Usage: node tools/smoke-plugin.mjs
13
+ */
14
+
15
+ import { readFileSync, existsSync } from 'node:fs'
16
+ import { dirname, join, resolve } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url))
20
+ const ROOT = resolve(HERE, '..')
21
+
22
+ const results = []
23
+ const check = (name, ok, detail = '') => results.push([name, !!ok, detail])
24
+
25
+ const read = (rel) => readFileSync(join(ROOT, rel), 'utf8')
26
+ const has = (rel) => existsSync(join(ROOT, rel))
27
+
28
+ // ── A. 包结构:市场与 `dsh plugin add` 看的就是这三样 ─────────────────────────────
29
+ let pkg = {}
30
+ try {
31
+ pkg = JSON.parse(read('package.json'))
32
+ check('package.json 可解析', true)
33
+ } catch (e) {
34
+ check('package.json 可解析', false, String(e.message))
35
+ }
36
+
37
+ check('声明了 dsh.bundle.patch(市场前置条件)', !!pkg.dsh?.bundle?.patch, String(pkg.dsh?.bundle?.patch))
38
+ const patchRel = String(pkg.dsh?.bundle?.patch || '').replace(/^\.\//, '')
39
+ check(`patch 文件存在(${patchRel})`, patchRel !== '' && has(patchRel))
40
+
41
+ const patchText = has(patchRel) ? read(patchRel) : ''
42
+ const patchId = (patchText.match(/^\s*-\s*id:\s*(.+)$/m) || [])[1]?.trim().replace(/^["']|["']$/g, '')
43
+ const patchName = (patchText.match(/^\s*name:\s*(.+)$/m) || [])[1]?.trim().replace(/^["']|["']$/g, '')
44
+
45
+ const plugin = await import('../lib/index.js')
46
+
47
+ check('patch 的 id 与插件 export name 一致', patchId === plugin.name, `patch=${patchId} plugin=${plugin.name}`)
48
+ check('patch 的 name 与 package.json name 一致', patchName === pkg.name, `patch=${patchName} pkg=${pkg.name}`)
49
+ check('插件声明 inject 了 skills 服务', Array.isArray(plugin.inject) && plugin.inject.includes('skills'), JSON.stringify(plugin.inject))
50
+ check('插件导出了 apply()', typeof plugin.apply === 'function')
51
+
52
+ // ── B. 挂载的副作用:apply() 必须真的注册一个 provider ──────────────────────────
53
+ const registered = []
54
+ const fakeCtx = { skills: { registerProvider: (create) => registered.push(create) } }
55
+ try {
56
+ plugin.apply(fakeCtx)
57
+ check('apply() 注册了 provider(看副作用,不看有没有报错)', registered.length === 1, `注册数=${registered.length}`)
58
+ } catch (e) {
59
+ check('apply() 注册了 provider(看副作用,不看有没有报错)', false, String(e.message))
60
+ }
61
+
62
+ const provider = registered.length === 1 ? registered[0]() : null
63
+ check('provider 暴露 list() 与 get()', typeof provider?.list === 'function' && typeof provider?.get === 'function')
64
+
65
+ // ── C. 候选:必须能过 DSH 注册表的校验 ────────────────────────────────────────
66
+ let candidates = []
67
+ try {
68
+ candidates = await provider.list({})
69
+ check('list() 返回一条技能', candidates.length === 1, `条数=${candidates.length}`)
70
+ } catch (e) {
71
+ check('list() 返回一条技能', false, String(e.message))
72
+ }
73
+
74
+ const cand = candidates[0] || {}
75
+ check('候选 name 是 kebab-case', plugin.isSkillName(cand.name), String(cand.name))
76
+ check('候选 description 非空', typeof cand.description === 'string' && cand.description.length > 0)
77
+ check('候选 source 是 bundled', cand.source === 'bundled', String(cand.source))
78
+ check('候选 rank 是 bundled 档 600', cand.rank === 600, String(cand.rank))
79
+ check('候选 provider 与插件名一致', cand.provider === plugin.name, String(cand.provider))
80
+ check('resourceBase 指向包根(正文里的 tools/… 才可解析)',
81
+ cand.resourceBase?.kind === 'directory' && resolve(cand.resourceBase.path) === ROOT,
82
+ String(cand.resourceBase?.path))
83
+
84
+ // 单一真相源:候选描述就是 SKILL.md 的 frontmatter,不是另一份手抄。
85
+ const fm = plugin.parseFrontmatter(read('SKILL.md')).fm
86
+ check('候选描述 = SKILL.md frontmatter 的 description(单一真相源)', cand.description === fm.description)
87
+ check('SKILL.md frontmatter 的 name 与候选一致', fm.name === cand.name, `${fm.name} vs ${cand.name}`)
88
+ check('SKILL.md frontmatter 的 version 存在', !!fm.version, String(fm.version))
89
+ check('SKILL.md 的 name 与包内的调用名一致', plugin.name === 'fact-check', plugin.name)
90
+
91
+ // ── D. 正文:应能被读出来,且已剥掉 frontmatter ────────────────────────────────
92
+ let body = ''
93
+ try {
94
+ const def = await provider.get(cand)
95
+ body = def.content
96
+ check('get() 返回定义且 content 非空', typeof body === 'string' && body.length > 0, `${body.length} 字符`)
97
+ check('get() 的正文已剥掉 frontmatter', !body.startsWith('---'), body.slice(0, 20).replace(/\n/g, '\\n'))
98
+ check('get() 的正文含技能标志段(## 铁律)', body.includes('## 铁律'))
99
+ check('get() 的 invocation 与候选一致', JSON.stringify(def.invocation) === JSON.stringify(cand.invocation))
100
+ } catch (e) {
101
+ check('get() 返回定义且 content 非空', false, String(e.message))
102
+ }
103
+
104
+ // name 不符时必须抛错(否则会交出一份名不副实的定义)
105
+ let threw = false
106
+ try {
107
+ await provider.get({ ...cand, name: 'not-the-same-name' })
108
+ } catch {
109
+ threw = true
110
+ }
111
+ check('名字不符时 get() 抛错而不是交出不对的定义', threw)
112
+
113
+ // ── E. 可移植:代码与 patch 里不得硬编码本机路径(与 route/jev-verdict 同一判据)─
114
+ const HOST_PATH = /(^|[^A-Za-z0-9])([A-Za-z]:[\\/]|\/home\/|\/Users\/|\\\\wsl)/m
115
+ for (const rel of ['lib/index.js', patchRel]) {
116
+ const hit = HOST_PATH.exec(read(rel))
117
+ check(`可移植:${rel} 不硬编码本机路径`, !hit, hit ? `命中 ${JSON.stringify(hit[0])}` : '')
118
+ }
119
+
120
+ // ── 汇总 ─────────────────────────────────────────────────────────────────────
121
+ let failed = 0
122
+ for (const [n, ok, detail] of results) {
123
+ console.log(` ${ok ? '✓' : '✗'} ${n}${!ok && detail ? ` — ${detail}` : ''}`)
124
+ if (!ok) failed++
125
+ }
126
+ console.log(`\n${results.length - failed}/${results.length} 通过`)
127
+ if (failed === 0) {
128
+ console.log('结论:插件可挂载,候选与正文都取自 SKILL.md(改 SKILL.md 无需改代码)。')
129
+ }
130
+ process.exit(failed === 0 ? 0 : 1)