@sidady/dsh-steward 0.9.12 → 0.9.15

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/ACCEPTANCE.md CHANGED
@@ -37,38 +37,67 @@
37
37
  > 还有一条**静态判据**的用法:像「渲染处不许把 ISO 的 T 换成空格」这类规约(v0.9.11),
38
38
  > 与其等它在界面上出错,不如直接扫源码——但静态判据必须**验证过会红**,否则它只是装饰。
39
39
 
40
- > **2026-11-10 五次修订(v0.9.12):基线升到 v0.9.12 / 281 断言,新增 A14 一行。**
41
- > 这轮换的是配色,看似「只是好看」,实际改的是**信息语义**:原来选中态靠变蓝表示,
42
- > 而蓝在同一块面板里还表示 info 级发现——颜色同时承担「选中」和「严重度」两件事,
43
- > 看的人就没法只看颜色判断状态。改成 baseline 的反相选中后,颜色只剩「数据」这一个含义。
44
- > A14 要抓的是回流:`check.mjs` 只抓「硬编码的灰」,抓不到「自造一套蓝」——
45
- > 那只是另一种饱和度,checker 无从判断谁对,所以真正的判据落在了 smoke 的静态扫描里。
40
+ > **2026-11-11 六次修订(v0.9.14):基线升到 v0.9.14 / 292 断言,新增 A15 与 A16 两行。**
41
+ > v0.9.13 与 v0.9.14 修的是同一个真机故障的两层:面板画得出来、每个请求都 404。
42
+ > 第一层(v0.9.13)是**说法**问题——把 404 翻译成「接口没挂上」,但它把原因只归给了热重载。
43
+ > 第二层(v0.9.14)才是**病因**:冷启动时 `webServer` 与 `tools` 的 apply 排在本插件之后,
44
+ > 旧的 `getService(ctx,'webServer')` 一次性探测拿到 null,路由与两个工具都没注册,
45
+ > 而三处注册外面都包着 fail-soft `try/catch`,于是**失败完全无声**。
46
+ > 这两行分别钉住「说得准」和「挂得上」:
47
+ > - **A15**「路由/工具是否注册成功,能不能被看见」——错把「面板画出来了」当成「插件在工作」。
48
+ > - **A16**「冷启动与热重载是不是被当成同一个病因」——症状相同、修法不同,说错会让人白等一次重启。
46
49
  >
47
- ## 基线快照(2026-11-10 15:40 采集(v0.9.12 重采),回看时用来对照)
50
+ > 还有一条更一般的教训值得记在这里:**fail-soft 是正确设计,但它的代价必须是可观测**。
51
+ > 这次不是注册逻辑写错了,是「注册失败」这个事实没有任何出口——面板照常渲染、`ping` 照常回
52
+ > `{"ok":true}`、日志里只有一行 warn。**一个不会喊的守卫,和没有守卫是一样的。**
53
+
54
+ > **2026-11-11 七次修订(v0.9.15):基线升到 v0.9.15 / 313 断言,新增 A17–A21 五行。**
55
+ > 这一轮不是修 bug,是把职能设定里「本该有、却一直缺」的几件补齐:
56
+ > - **P0**:`setSaved` 把 UTC 切片当本地时间贴给东八区(差 8 小时),而静态判据只认单引号写法、
57
+ > 双引号绕过去了;`ui.autoScanMinutes` 是个死设置——界面显示「已保存」、行为永远按硬编码的 10 分钟。
58
+ > - **P1**:只有「静音」没有「这条判错了」入口,两周验收只能人肉回看,管家学不会;
59
+ > 现在 `feedback.json` + plan→confirm→audit 同一条链,判错后降 40 分、不再计直达处置、可撤销、不过期。
60
+ > - **P2**:趋势页只是流水行、只报坏不报好、15 个 finding 里 10 个没有直达(其中一条还指向不存在的动作卡)、
61
+ > 自检结果只在 `ping` 里。现在服务端算好 `trendVerdict` 的人话、好转/连续恶化会主动弹、
62
+ > 面板显示 `wiring`、删掉那条假直达并补上 `backup-freshness → backup`。
63
+ >
64
+ > 五行的共同点:**它们都是「界面上看着有、其实没接上」**——设置一个值不生效、一个按钮点不到东西、
65
+ > 一个时间差 8 小时、一条结论没人说。这类缺陷不会让体检报错,只会让人对面板失去信任。
66
+ > 所以 A17–A21 一律判「接没接上 / 说没说清」,不判「代码写得对不对」。
67
+
68
+ ## 基线快照(2026-11-11 19:40 采集(v0.9.15 重采),回看时用来对照)
48
69
 
49
70
  | 项 | 基线值 |
50
71
  |---|---|
51
- | bundle 版本 | `@sidady/dsh-steward` v0.9.12,installed + enabled(ping 与 package.json 一致) |
52
- | 本地验证 | 9 个 JS `node --check` 全过,`smoke.mjs` **281 passed / 0 failed** |
72
+ | bundle 版本 | `@sidady/dsh-steward` v0.9.15,installed + enabled;ping 回报 `version:"0.9.15"` 且 `wiring.route:"registered"`(三条 via 全 inject) |
73
+ | npm 上的版本 | **`0.9.14`**(`dist-tags.latest`)——本地已是 0.9.15,**尚未 publish**,见下「版本号与基线数字的关系」 |
74
+ | GitHub | `stallenlu/dsh-steward` public,`main` 最新提交 `e3ef16d`(docs 重采到 v0.9.14);v0.9.15 代码待 commit + push |
75
+ | 目录条目 | awesome-dsh-plugin PR **#14042** 开着;仓库年龄门 2026-11-11T15:04:07Z 开,之后第一次 regate 北京 2026-11-12 02:19;合并后 dshmarket 才拉得到 |
76
+ | 本地验证 | 9 个 JS `node --check` 全过,`smoke.mjs` **313 passed / 0 failed**(含 21 条 v0.9.15 静态判据,7 条反向验证过会红) |
53
77
  | 体检结论 | `level=warning`,`counts={critical:0, warning:2, info:10, muted:0}`,`errors=[]`,共 12 条 findings |
54
- | crash 日志 | **4 份**:`crash-2026-11-07T02-53-24-868Z-web-boot.log`(10199 B) / `crash-2026-11-08T09-10-33-857Z-web-boot.log`(61087 B) / `crash-2026-11-09T23-27-20-521Z-web-boot.log`(31821 B) / `crash-2026-11-09T23-28-38-158Z-web-boot.log`(31821 B) |
55
- | audit.jsonl | 40 行(v0.9.8 起有 2000 行上限;`ok:false` 行仍在,说明拒绝路径活着) |
56
- | trend.json | **4 个点**,最新 `2026-11-10T03:50:33.499Z`;`cFreeBytes` 87.3 GB(更早一点 89.4 GB,不是恒定值) |
57
- | acked.json | 空数组 `[]`(v0.7.7 起 ack 带 `expiresAt`) |
58
- | 工作区 | 7 个 / 合计约 **192 MB**(采集时 201341401 B;这个数每次都在变,回看时只看「7 个」与增长判定,不要咬死体积);判据是**增长**不是绝对体积,本轮「与上次相比无显著增长」 |
59
- | 插件更新 | 32 个已装包比对,13 个有新版本 + 3 个**已装比缓存新**(缓存偏旧,不算升级),0 个无法比较 |
78
+ | 两条 warning | `plugin-change`(本会话记录到 1 次插件变更,属事实非异常)+ `data-at-risk`(易失数据不在默认备份分区,长期项) |
79
+ | `trendVerdict` | **新增字段**:`{state:"stable", worseningStreak:0, text:"与上一次体检相比没有明显变化。"}`;服务端算好人话、客户端只渲染 |
80
+ | `autoScanMinutes` / `wiring` | report 返回体新增:`autoScanMinutes: 10`、`wiring.route:"registered"`;面板生成时间行下显示「接口已挂上 · 路由与工具随服务就绪后注册」 |
81
+ | crash 日志 | **仍 4 份**,最晚 `crash-2026-11-09T23-28-38-158Z-web-boot.log`;v0.9.13 起**零新增** |
82
+ | audit.jsonl | 40 行(2000 行上限) |
83
+ | trend.json | **4 个点**,`cFreeBytes` 本轮 ≈74.8 GB(80363397120 B) |
84
+ | acked.json / feedback.json | `[]` / **尚不存在**(v0.9.15 新增,没有用户点过「这条判错了」就不会建文件;首次标记后才有) |
85
+ | 工作区 | 7 个 / 合计约 **232 MB**(采集时 243226827 B;只看「7 个」与增长判定,不要咬死体积) |
86
+ | 插件更新 | 比对 11 个已装包:**0 个落后、1 个缓存偏旧、0 个无法比较** |
60
87
  | 数据源覆盖 | `coverage` **7/7** = `data`,`unavailable=[]`,`errors=[]` |
61
- | 配置指纹 | `cordis.patch.yml` `1b85f7154b1261fd`(60159 B / 1994 行) / `package.json` `dc6c2f83d584c1a0`(2784 B / 87 行)。version 升会改 `package.json` 指纹,属正常;**块数变少**才报警 |
62
- | 设置 | `settings.json` 存在,`savedAt=2026-11-10T03:58:45.090Z`;`ttl.plugin-updates=14`(已回默认),`ui.entry=right`,`ui.toast=critical`,`ui.toastSeconds=12`,`model.probeOnReport=cached`,`perProvider=2`,`maxTotal=12`,`growthWarnMb=20` |
63
- | 客户端包 | 38 个声明了 `dsh.client`,checked 38 / unreadable 0 / withoutId 0;`inject` 只声明 `@deepseek-ai/dsh-client-ui-slots`(locale 已于 v0.9.10 去掉虚挂) |
64
- | 默认模型 | 12 个哨兵探针:8 ok / 0 dead / 4 busy(busy = 「没有返回内容」,不算坏);默认模型可用 4504 ms |
65
- | 加密备份 | 2 份,最新 `dsh-config-2026-11-09-1749ba.zip`(95.7 MB)1 天前;另一份 `dsh-config-20261108-180014-ab10fd.zip`(65.6 MB) |
88
+ | 配置指纹 | `cordis.patch.yml` `40c7de15f6a7500d`(82811 B / 2720 行) / `profile package.json` `bc95db08d316c10c`(2837 B / 87 行)。**patch 体量在变**(60159→89178→82811 B):回看只比 `- id:` 块数有没有减少,不要比字节 |
89
+ | 设置 | `savedAt` 沿用旧值;新增的活设置 `ui.autoScanMinutes=10` 已穿透到 report 返回体与客户端阈值 |
90
+ | 客户端包 | 38 个声明 `dsh.client`,checked 38 / unreadable 0 / withoutId 0;`inject` 只声明 `@deepseek-ai/dsh-client-ui-slots` |
91
+ | 默认模型 | `model-health`:`state:"ok"`、4096 ms;默认 `workbuddy/hy4free` 可用 |
92
+ | 兼容 / 加密备份 | 11 个声明门槛的包都适配宿主 `0.2.2-rc.1`;加密备份 2 份,最新 `dsh-config-2026-11-09-1749ba.zip`(100382581 B / 95.7 MB) |
66
93
 
67
94
  > **版本号与基线数字的关系**:本表的数字是「当天真实跑出来的」,不是「必须永远等于这个」;
68
95
  > 回看时若断言数已经涨了(比如又加了几条),那是覆盖变多,记一笔新数字即可,不算退化。
69
- > 真正要盯的是:crash 日志有没有新增、`errors` 是否仍为空、`coverage` 是否仍 7/7。
70
- > **回看前先做一件事**:把 `package.json` 的 version 与 ping 的 version 对一下。两者不等说明
71
- > 磁盘改了但宿主没重载——此时面板看到的是旧代码,任何 A 项的结论都不作数。
96
+ > 真正要盯的是:crash 日志有没有新增、`errors` 是否仍为空、`coverage` 是否仍 7/7、
97
+ > `ping.wiring.route` 是否仍是 `registered`、`trendVerdict` / `autoScanMinutes` / `wiring` 三个返回体字段是否还在。
98
+ > **回看前先做一件事**:把 `package.json` 的 version、npm 上的 latest、ping 的 version 三个对一下。
99
+ > 三者不等说明「磁盘改了 / 发了 / 宿主没重载」里有一环断了——此时面板看到的是旧代码,任何 A 项的结论都不作数。
100
+ > **本次基线三者不等是已知状态**:本地 0.9.15 / npm 0.9.14 / ping 0.9.15,因为 0.9.15 还没发。发布后应回到三者一致。
72
101
 
73
102
  ## 检查表
74
103
 
@@ -100,6 +129,13 @@
100
129
  | A12 | 模型健康是否把「忙」当「坏」 | 报告 `model-health` 的 `data.state` + `deadModels` | 只把 404/422(上游明确拒绝路由)与「连续两轮拿不到内容」算坏;429/5xx/超时/空回复一律记 busy 且不升 warning、不进 `deadModels`。若某个免费模型只是一时冷却却被写成「已不存在」→ ❌(这是最容易被免费额度误伤的一项) |
101
130
  | A13 | 「多久以前」与「比启动早多久」有没有被混成一个数 | 报告 `boot-crash-latest` 的 `data.ageHours` / `bootLagHours` / `ageText` / `bootLagText` | 四个数各司其职:`ageHours` 是**距现在**多久、`bootLagHours` 是**距本次启动**多久,两者可以相差很大(真机 7.3 小时 vs 0.01 小时)。标题/正文里出现「约 0 小时前」而事件实际已过去数小时 → ❌(v0.9.10 前就是这样)。同类要一并查:列表截断有没有说出「另有 N 个」(工作区 / 备份 / 采集告警,v0.9.10) |
102
131
  | A14 | 配色是不是又从 baseline 溜回自造色 | `node check.mjs ../dsh-steward/lib/client.js` + `smoke.mjs` 的配色静态判据 | 两个都必过:checker 报 `no hardcoded neutral` 不给 error 只说明「没有硬编码的灰」,**抓不到「自造一套蓝」**(那是另一种饱和度,不是灰);所以真正的判据是 smoke 那 7 条 —— baseline 块外不许出现非中性的 `rgba()/rgb()`、不许出现 `var(--dsw` 之外的裸 hex、选中态必须用 `C.solid`/`C.on` 反相。若面板上又出现 `rgba(90,159,212,…)` 这类蓝、或选中态靠变蓝表示 → ❌(v0.9.12 前就是这样,12 处) |
132
+ | A15 | 路由与工具是不是「注册了但没说」 | `ping` 的 `wiring.route` / `wiring.via` + 面板能不能出报告 | 必须 `wiring.route === "registered"`,且 `wiring.via` 里 `systemPrompt` / `webServer` / `tools` 三条都是 `inject`。若面板画得出来但每个请求 404、`dsh_steward_*` 工具又「未知工具」→ ❌(v0.9.14 前就是这样:冷启动竞态下三处注册全被静默跳过,而 `ping` 还回 `{"ok":true}`) |
133
+ | A16 | 冷启动与热重载有没有被当成同一个病因 | 面板 404 时的提示文案 + 复现条件 | 两种情境都要能讲对:**热重载**=改插件文件后旧路由被销毁;**冷启动**=`webServer` 比插件晚 apply。文案里只写其中一种 → ❌(v0.9.13 只写热重载,于是用户重启后看到同一句提示,等于被指错了方向)。判据不看措辞好不好听,看它有没有把「我刚重启过」这个人排除掉 |
134
+ | A17 | 本地时间有没有被 UTC 冒充(两种引号都查) | `smoke.mjs` 的 v0.9.11 / v0.9.15 静态判据 + 面板「设置」的保存时间 | 判据必须同时认单引号与双引号的 `.replace("T"," ")` 写法,且 `setSaved` 走 `localStamp()`。若把保存时间显示成 UTC(比实际差 8 小时)、或改回 `.replace("T"," ")` 后静态判据仍全绿 → ❌(v0.9.15 前双引号绕过判据) |
135
+ | A18 | 设置项是不是真的接上了(**抓死设置**) | report 返回体 `autoScanMinutes` + 客户端 `useEffect` 阈值 | 改 `ui.autoScanMinutes` 后:①report 返回体里的值跟着变;②切回面板时的刷新阈值跟着变(不许再出现硬编码 `10 * 60 * 1000`)。若界面显示「已保存」而行为不变 → ❌(v0.9.15 前就是这样) |
136
+ | A19 | 误报反馈闭环是否成立 | `feedback.json` + 面板「这条判错了 / 撤销判错」 + `audit.jsonl` | 四件都要真:①点「判错」走 plan→confirm→audit 同一条链、有 hash;②该 finding 打上 `falsePositive`、score 降 40、不再计直达处置、证据仍保留;③「撤销」能恢复;④条目**不过期**(静音会过期,判错不该悄悄重新武装)。任何一件没接上 → ❌ |
137
+ | A20 | 趋势结论与好转通知是不是服务端算的人话 | report `trendVerdict` + 面板「审计趋势」结论行 + ToastHost | `trendVerdict` 必须有 `state`(worsening / recovered / stable)、`worseningStreak`、中文 `text`,由服务端 `summarizeTrend()` 算好、客户端只渲染;好转或连续 ≥2 次变差要主动弹一条(用 `generatedAt` 去重,不许反复弹)。若趋势页仍只是流水行、或只报坏不报好 → ❌ |
138
+ | A21 | 直达按钮不造假 + 自检结果用户可见 | `ACTION_FOR` + 动作卡清单 + 面板生成时间行 | ①`ACTION_FOR` 里每一条映射都要有对应的动作卡(v0.9.15 删掉了指向不存在卡片的 `model-health → model-default`,补上 `backup-freshness → backup`);②`wiring.route` 要显示在面板上,没挂上时给红字与恢复办法,不能只藏在 `ping` 里。若点了「去看看」跳到一个不存在的动作、或路由挂不上却面板无提示 → ❌ |
103
139
 
104
140
  ### B. 可回溯(每次动作都能查账)
105
141
 
@@ -114,7 +150,7 @@
114
150
  | # | 检查项 | 数据源 | 打勾条件 |
115
151
  |---|---|---|---|
116
152
  | C1 | 没有代跑提权命令 | 会话记录 + `audit.jsonl` | 全期零次 `DISM` / `diskpart compact` / 卸载程序被 Agent 执行(只呈现过建议)→ ✅ |
117
- | C2 | 没有「未经授权就动手」 | `audit.jsonl` 每条 `kind` ∈ {clean,backup,disable,enable,knowledge,ack,unack,model-default} 且都有 `hash` | 不存在 hash 为空却 `ok:true` 的执行行 → ✅ |
153
+ | C2 | 没有「未经授权就动手」 | `audit.jsonl` 每条 `kind` ∈ {clean,backup,disable,enable,knowledge,ack,unack,model-default,feedback,unfeedback} 且都有 `hash` | 不存在 hash 为空却 `ok:true` 的执行行 → ✅ |
118
154
  | C3 | 口令红线:11-16 周提醒只提醒不代做 | 会话记录(2026-11-16 10:00 那次触发) | Agent 没有索要口令、没有代跑、没有提议代跑 → ✅;任何一项违反 → ❌ 并回写 USER.md |
119
155
  | C4 | 坏请求不会触发写动作 | 对路由发畸形 body | `{oops` / `{}` / 超 16 KB / JSON 数组 必须全部 400,且**不得**因此跑一次体检(v0.9.8 前会把空 action 当 report)→ ✅ |
120
156
 
@@ -122,7 +158,7 @@
122
158
 
123
159
  | # | 检查项 | 数据源 | 打勾条件 |
124
160
  |---|---|---|---|
125
- | D1 | 依赖缺失只降级不崩溃 | `node smoke.mjs` 的 fail-soft 断言 | 回看当天跑一次 smoke,仍全绿(基线 281 passed / 0 failed)→ ✅(若期间又加断言,以当天全绿为准并记下新数字) |
161
+ | D1 | 依赖缺失只降级不崩溃 | `node smoke.mjs` 的 fail-soft 断言 | 回看当天跑一次 smoke,仍全绿(基线 313 passed / 0 failed)→ ✅(若期间又加断言,以当天全绿为准并记下新数字) |
126
162
  | D2 | 热挂载后落库一致 | profile `package.json` + `/api/dsh-steward/` 200 + ping 版本 | grep `dsh-steward`=2、路由 200、ping 的 `version` = `package.json` 的 `version` 三者一致 → ✅。**注意**:`remove_bundle` 会因 `bundle-in-use` 失败,实际路径是 `set_bundle enabled:false → true`;ping 版本正确也**不**等于新代码已加载(读的是磁盘 package.json),要看新文案是否出现 |
127
163
  | D3 | 可选数据源缺失不拖垮报告 | 报告 `status` 与 `coverage` | 缺一个可选源时 `status` 仍是 `ok`、只在 `coverage` 里记 `unavailable` → ✅ |
128
164
  | D4 | 慢请求不会无限等待 | 面板发一次 `model-scan` | 服务端 3 路并发(12 探针最坏 ≈180 s),前端 `report`/`model-scan` 预算 240 s、普通动作 30 s。若面板转圈超过 5 分钟仍无任何提示 → ❌ |
@@ -158,6 +194,18 @@ curl -s -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-typ
158
194
 
159
195
  # 5) 坏请求不该跑体检(C4)
160
196
  curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-type: application/json' -d '{oops'
197
+
198
+ # 6) 路由到底挂上没有(A15)——比「面板能不能画出来」可靠得多
199
+ # 期望:version 三方一致,且 wiring.route === registered
200
+ # wiring.via 里 systemPrompt / webServer / tools 三条都是 inject
201
+ curl -s -o "$TMP/ping.json" -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-type: application/json' -d '{"action":"ping"}'
202
+ cat "$TMP/ping.json"
203
+
204
+ # 7) v0.9.15 新增字段(A18–A21)——确认「接上了」而不只是「写好了」
205
+ curl -s -o "$TMP/rep.json" -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-type: application/json' -d '{"action":"report"}'
206
+ node -e "const r=require(process.argv[1]); console.log('autoScanMinutes:',r.autoScanMinutes,'wiring:',r.wiring.route,'trendVerdict:',r.report.trendVerdict.state,r.report.trendVerdict.text)" "$TMP/rep.json"
207
+ # 误报反馈:点一次「这条判错了」后应出现 feedback.json,且该 finding 带 falsePositive、score 降 40
208
+ cat ~/.dsh/dsh-steward/feedback.json 2>/dev/null || echo '(还没有判错记录)'
161
209
  ```
162
210
 
163
211
  > 端口每轮启动都会变,用运行时上下文里的 GUI URL,不要沿用旧端口。
@@ -194,8 +242,23 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<当前端口>
194
242
  - **依赖声明必须与代码一致**:`dsh.client.inject` 里声明了却零调用的包就是虚挂,它今天不报错、将来宿主改注入方式时会以「找不到依赖」的形式炸掉(v0.9.10 去掉了 locale)。判定方式是 grep 实际调用,不是看别的插件怎么写。
195
243
  - **静态判据也必须验证会红**:像「渲染处不许把 ISO 的 T 换成空格」这种扫源码的断言,写完要故意改回旧写法确认它 FAIL,否则它只是装饰(v0.9.11 做了这次反向验证)。这条对所有 `smoke.mjs` 里的规约型断言都适用。
196
244
  - **测试全绿不等于包能装**:`files` 白名单漏一个文件时,从源码树跑的断言全绿、装包的人一启动就 `ERR_MODULE_NOT_FOUND` 并拖垮整个 Web UI(v0.9.9 的 `lib/models.js`)。发布前必须跑一次 `npm pack` 并解包核对相对 import(D5)。
245
+ - **fail-soft 的代价必须是可观测**:
246
+ 注册失败、依赖缺失这类「不该拖垮启动」的降级是正确的,但如果它连一条能查到的状态都不留,
247
+ 就等于没有守卫。v0.9.14 之前三处注册全包在 `try/catch` 里只 warn 一行,
248
+ 结果面板照常渲染、`ping` 照常回 `{ok:true}`、路由却从未注册——
249
+ **一个不会喊的守卫和没有守卫是一样的**。
250
+ 判据:任何 fail-soft 分支都要能把「我降级了」暴露到 ping / 报告 / 日志三处里至少一处(A15)。
197
251
  - **info 不算打扰,warning 才算**:判误报时先看 severity。info 天天在、你从不理它,是噪音的候选而非误报;warning 天天在、你无事可做,就是误报。
198
252
  - 出现 ❌ 时不改判定标准,只记原因;判定标准本身要改,必须写下「为什么原标准不可执行」,不许事后放宽。
253
+ - **「界面上有」不等于「接上了」**:一个设置项显示「已保存」但行为按硬编码走、一个按钮指向不存在的动作卡、
254
+ 一个时间差 8 小时、一条趋势结论没人说——这些都不让体检报错,只让人对面板失去信任。
255
+ A17–A21 一律判「接没接上 / 说没说清」,不判「代码写得对不对」。
256
+ - **静态判据本身也要防绕过**:v0.9.11 那条「不许把 ISO 的 T 换成空格」只认单引号,v0.9.15 前双引号写法
257
+ 就绕过去了。规约型断言写完要问一句「换一种引号 / 换一种写法还能不能抓到」,并反向验证会红。
258
+ - **判错不是静音**:静音是「我知道了先不修」(会过期、会回来),判错是「这条结论本身错了」
259
+ (不过期、降权、但证据保留)。两者混成一个入口,两周验收就学不到东西(A19)。
260
+ - **好转也要说**:只报坏不报好,用户永远不知道管家在变好还是在变差;但好转通知必须去重,
261
+ 否则每次体检都弹一次,本身就成了新的噪音(A20)。
199
262
 
200
263
  ## 回看结论栏(11-23 填)
201
264
 
package/README.md CHANGED
@@ -1,7 +1,123 @@
1
1
  # dsh-steward — 智能管家(体检 + 授权执行)插件
2
2
 
3
- **状态:v0.9.12 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项)**
3
+ **状态:v0.9.15 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项)**
4
4
 
5
+ ## v0.9.15(把「该有的职能」补齐:时间、设置、反馈、趋势、直达)
6
+
7
+ 这一轮按职能设定逐条对照代码,补上八处「功能上没考虑到、但角色上应该有」的缺口。
8
+ 顺序按影响排:先修真 bug 与死设置,再补反馈闭环,最后是趋势与直达。
9
+
10
+ ### P0:两个「说了但没做」
11
+
12
+ - **设置页的「上次保存」差 8 小时**:`savedAt` 是 UTC ISO,旧代码用 `.replace("T"," ")` 切片
13
+ 当成当地时间贴出来。静态判据只认单引号写法,双引号那次悄悄绕过去了——现在两种引号都查,
14
+ 并且这一行改走 `localStamp()`。
15
+ - **`ui.autoScanMinutes` 是个死设置**:设置里能存 1–240 分钟、界面也回「已保存」,但客户端
16
+ 把刷新阈值写死成 10 分钟。现在服务端把夹取后的值随报告下发,客户端按它算阈值;
17
+ 「设置改了却没反应」正是验收表 A9 要抓的静默失效,不该长在自己家。
18
+
19
+ ### P1:误报要能被纠正,管家才学得会
20
+
21
+ 只有「已知晓(静音)」时,用户看到一条判错的结论,除了不理它没有别的办法。
22
+ 新增 `feedback.json` 与面板按钮「这条判错了 / 撤销判错」,走与静音完全相同的
23
+ plan → 一次性指纹 → confirm → audit 链。标记后:证据仍然保留、不再计「有直达处置」、
24
+ 重要性分数降 40 并写进 `scoreReasons`,所以它会排到后面而不是消失。
25
+ **这是可逆的判定,不是删除**。刻意不进语言层(brain.js / intent.js 不加这个 kind):
26
+ 「把某条具体发现判错」用自然语言说不清是哪一条,按钮更不易误触。
27
+
28
+ ### P2:趋势要会说话,直达不许造假
29
+
30
+ - 服务端新增 `summarizeTrend()`,用上一次报告算出 `trendVerdict`(好转 / 连续变差 / 平稳)
31
+ **和一句人话**,客户端只渲染——符合「服务端算好人话、客户端只渲染」。
32
+ 审计趋势顶部显示结论,恢复或连续两次变差时右下角主动弹一条(不再只报坏不报好)。
33
+ - `ACTION_FOR` 里 `model-health → model-default` 指向一张并不存在的动作卡,是假直达:
34
+ 删掉,并把 `backup-freshness → backup` 补上(备份新鲜度该能一步跳到备份卡)。
35
+ 其余只有文字建议的 finding 不给按钮——宁可少给,也不给一个点了没反应的按钮。
36
+ - `wiring`(路由 / 工具到底注册上没有,v0.9.14 起只在 ping 里)随报告下发,
37
+ 面板在生成时间旁显示「接口已挂上」;没挂上时给出可操作的提示。
38
+
39
+ ### 边界
40
+
41
+ `feedback` 与 `ack` 都只写管家自己的数据目录,不动任何系统状态;口令仍绝不由 Agent 生成或代输。
42
+
43
+ ## v0.9.14(冷启动竞态:路由与工具都注册了,只是没在最合适的那一刻)
44
+
45
+ ### 症状和真正的原因不是同一件事
46
+
47
+ 重启之后面板照旧报 `读取失败: 接口没有挂上(HTTP 404)`。v0.9.13 已经能把这句
48
+ 话翻译成人话,但它把原因归给了「热重载」——而这一次是冷启动,热重载的解释
49
+ 根本对不上。真正的原因更安静:
50
+
51
+ `apply()` 里原本是这样拿服务的:
52
+
53
+ ```js
54
+ const webServer = getService(ctx, 'webServer');
55
+ if (webServer && typeof webServer.register === 'function') { ... }
56
+ ```
57
+
58
+ `getService` 只做 `ctx.get(key)` 或 `ctx[key]`,而 **`ctx.get` 在 Cordis 里只能拿到
59
+ 那一刻已经 apply 完的服务**。冷启动时 `webServer` 与 `tools` 的 apply 都排在本插件
60
+ 之后,于是那一次探测拿到 `null`,整个 `if` 被跳过:前缀路由没注册、两个
61
+ `dsh_steward_*` 工具也没注册。外面的 `try { ... } catch { /* ignore */ }` 是刻意的
62
+ fail-soft(注册失败不该拖垮 boot),代价是**失败完全无声**——面板照常从 manifest
63
+ 画出来,然后每个请求都撞在「没有命名路由匹配 → 404」上。
64
+
65
+ 所以这不是「路由写错了」,是「注册发生在错误的时刻」。热重载会是同一个症状,
66
+ 但它们是两个不同的病因,v0.9.13 的诊断文案只认识其中一种。
67
+
68
+ ### 改法:等它就绪,再注册
69
+
70
+ 新增 `whenService(ctx, keys, fn)`:优先走 `ctx.inject(keys, cb)`,回调在服务真正
71
+ 存在之后才执行;`:root` 上没有 `inject` 时退回旧的一次性探测(行为与从前一致,不会
72
+ 更差)。三处注册(`systemPrompt` 段、前缀路由、tools)全部改走它。
73
+
74
+ - 用 `ctx.inject` 而不是 `export const inject = [...]`,是因为硬 inject 会让本插件
75
+ 成为 **boot 依赖**——它的 apply 一旦抛错,整个 Web UI 起不来。这个插件的第一原则
76
+ 是「不许让面板成为新的崩溃源」,所以宁可延迟注册,也不升级成硬依赖。
77
+ - `ctx.effect` 仍然包在回调里,所以热重载时 dispose 依旧会被正确回收。
78
+
79
+ ### 失败必须能看见
80
+
81
+ 注册结果记在 `wiring.route` 里,`ping` 的返回体多一个字段:
82
+
83
+ ```
84
+ {"ok":true,...,"wiring":{"route":"registered"}}
85
+ ```
86
+
87
+ 下次再出现「面板画出来了但每个请求都 404」,只需要看一眼 ping:`registered` 说明
88
+ 路由在,问题在别处;`pending` 或 `failed: ...` 直接指到病因。**静默的 fail-soft 是
89
+ 这次真正的坑**——比注册失败本身更难查,因为它连一条可搜的日志都不留。
90
+
91
+ ### 这一版钉住的判据
92
+
93
+ `smoke.mjs` 新增五条静态判据(291 条断言):必须用 `ctx.inject` 延迟注册;三处注册
94
+ 全都走 `whenService`;**旧的裸 `try { const webServer = getService(...)` 写法必须彻底
95
+ 消失**;`wiring.route` 要记录成功与失败两种结果;`ping` 要把它带出来。反向验证过:
96
+ 把路由那段改回一次性探测,立刻 `289 passed, 2 failed`。
97
+
98
+ ## v0.9.13(路由没挂上时,别只说 HTTP 404)
99
+
100
+ 改插件源码会让宿主热重载:旧 fiber 被销毁,新 fiber 重新跑一遍 `apply()`。
101
+ 真机踩到的形态是——重载过后 `apply()` 里那段 `webServer.register({ kind: 'prefix', path: ROUTE_PREFIX })`
102
+ 没有接上,面板打开就只剩一行红字 `读取失败: HTTP 404`:bundle 是 enabled + installed,
103
+ 源码 `node --check` 全过,`import()` 也正常,只有路由不见了。
104
+
105
+ `HTTP 404` 这句话的问题不是不准,是**没法行动**:它和「服务端主动回了个 404」长得一模一样,
106
+ 而这两种情况的处置完全不同。所以这一版:
107
+
108
+ 1. **认得出来** —— 自己这条路由上的 404 单独成句:说清「面板加载了,但 /api/dsh-steward/ 没注册」,
109
+ 并点名最常见的原因(热重载后旧路由被销毁、新的没接上)。
110
+ 2. **说得出怎么办** —— 提示「设置里停用再启用一次」或「直接重启 DSH Web」,两者都真的能修。
111
+ 3. **重试一次就够** —— 热重载有一个几百毫秒的窗口,这时路由确实不在;重试一次不会把
112
+ 真正的故障藏起来,也不会让用户对着一个会自动好的状态点按钮。
113
+ 4. **别的错误不许挪用这套话** —— 非 404 的失败仍然原样报服务端给的原因,
114
+ 否则「热重载」就成了一个什么都往里装的筐。
115
+
116
+ 五条断言全部是**静态判据**(读 `lib/client.js` 自己的源码),并且反向验证过:把
117
+ `response.status === 404 && tries === 0` 改成 `if (false)`,`285 passed / 1 failed`。
118
+
119
+ > 顺带记一条运维经验:这个状态下**只有重启/重挂**能修,改代码不会修好它。
120
+ > 而 `plugin_manager set_bundle enabled:false` → `enabled:true` 就是重挂。
5
121
  ## v0.9.12(配色:从「自造一套蓝」改成 baseline 的六个 accent)
6
122
 
7
123
  面板里原本散着 12 处私有颜色:`rgba(90,159,212,.14)` 这种自造的蓝、`#e05555` 这种自造的红、
@@ -241,7 +357,7 @@ v0.7 给这个输入框加了「模型理解」:可选的第二通话方式,
241
357
  - `lib/brain.js`:v0.7 模型理解层。把一句话交给宿主 `llm` 服务,把模型输出归一成与 `intent.js` **完全同形状**的意图;JSON 抽取容错(容忍围栏与前后废话),各字段走白名单校验,任何不合规都降级为 clarify。**只翻译,不执行。**
242
358
  - `lib/index.js`:host(路由 + `dsh_steward_report` / `dsh_steward_act` 两个工具 + 事件订阅 + boot 后自动体检 + systemPrompt 注入)。
243
359
  - `lib/client.js`:client(`__ModuleLoader__.load` + 三 Tab 面板 + 动作卡 + 侧栏/右侧栏入口,全内联样式)。
244
- - `smoke.mjs`:本地验证(不启动宿主),281 项断言(含打包自检、时间戳与配色静态规约判据)。
360
+ - `smoke.mjs`:本地验证(不启动宿主),292 项断言(含打包自检、时间戳、配色与「延迟注册」四类静态规约判据)。
245
361
 
246
362
  ## 本地验证(不装、不重启)
247
363
  ```
package/lib/actions.js CHANGED
@@ -104,6 +104,16 @@ async function readAcked(deps) {
104
104
  });
105
105
  }
106
106
 
107
+ /** v0.9.15: 'this judgement was wrong' is distinct from 'I know, be quiet'.
108
+ * Entries are permanent until explicitly undone; expiry would silently
109
+ * re-arm a finding the user already told us was a false positive. */
110
+ async function readFeedback(deps) {
111
+ let list = [];
112
+ try { list = (await deps.io.readJson(deps.feedbackFile)) || []; } catch { list = []; }
113
+ if (!Array.isArray(list)) return [];
114
+ return list.filter((a) => a && typeof a === 'object' && typeof a.id === 'string');
115
+ }
116
+
107
117
  /** Default mute duration per finding id — a mutable condition gets a shorter
108
118
  * leash than a structural one. These are FALLBACKS only: v0.8.0 起真正的值来自
109
119
  * 用户设置(settings.ttl.<id> → settings.ttl.default),这里只作为 settings.js
@@ -135,7 +145,7 @@ function currentSettings(deps) {
135
145
  * - readBytes(file): Buffer|string — used ONLY to verify a freshly written
136
146
  * DCA1 container in-request; never persisted, never logged.
137
147
  * - now(): () => Date
138
- * - auditFile / trendFile / knowledgeFile / ackedFile: paths under plugin data dir.
148
+ * - auditFile / trendFile / knowledgeFile / ackedFile / feedbackFile: paths under plugin data dir.
139
149
  */
140
150
  export function createActionHub(deps) {
141
151
  const pending = new Map(); // hash -> { kind, plan, createdAt }
@@ -335,6 +345,29 @@ export function createActionHub(deps) {
335
345
  remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
336
346
  return { ok: true, planHash: hash, plan: p };
337
347
  }
348
+ if (kind === 'feedback' || kind === 'unfeedback') {
349
+ const report = typeof deps.getReport === 'function' ? deps.getReport() : null;
350
+ const id = typeof params.id === 'string' ? params.id.trim() : '';
351
+ if (!report || !Array.isArray(report.findings)) return { ok: false, error: '还没有体检报告——先做一次体检。' };
352
+ if (!id) return { ok: false, error: '需要 finding id(报告条目的编号)。' };
353
+ const f = report.findings.find((x) => x.id === id);
354
+ if (!f) return { ok: false, error: `报告里没有编号 "${id}" 的条目。` };
355
+ const list = await readFeedback(deps);
356
+ const marked = list.some((a) => a.id === id);
357
+ if (kind === 'feedback' && marked) return { ok: false, error: `「${f.title}」已经标记为判错了。` };
358
+ if (kind === 'unfeedback' && !marked) return { ok: false, error: `「${f.title}」没有被标记为判错。` };
359
+ const p = {
360
+ kind, findingId: id, title: f.title,
361
+ human: kind === 'feedback'
362
+ ? [`把「${f.title}」标记为这条判错了。`,
363
+ '效果:从下次体检起这条不再计直达处置、重要性分数降 40,但证据仍然保留,随时可撤销。',
364
+ '这只是告诉管家「这次的判断不准」,不会改动任何系统状态。']
365
+ : [`撤销「${f.title}」的判错标记,恢复它的正常评分。`],
366
+ };
367
+ const hash = hashPlan(kind, p);
368
+ remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
369
+ return { ok: true, planHash: hash, plan: p };
370
+ }
338
371
  if (kind === 'model-default') {
339
372
  // 只改「默认模型是哪一个」这一项选择,不碰任何插件的配置。
340
373
  // 这是三个模型类动作里唯一会写宿主状态的,所以它必须有 plan 与指纹。
@@ -487,6 +520,21 @@ export function createActionHub(deps) {
487
520
  '恢复:该条目上的「取消知晓」,或 Agent 工具 kind=unack。']
488
521
  : [`「${entry.plan.title}」已恢复计入风险计数。`],
489
522
  };
523
+ } else if (entry.kind === 'feedback' || entry.kind === 'unfeedback') {
524
+ let list = await readFeedback(deps);
525
+ const id = entry.plan.findingId;
526
+ if (entry.kind === 'feedback') {
527
+ if (!list.some((a) => a.id === id)) list.push({ id, title: entry.plan.title, at: deps.now().toISOString() });
528
+ } else {
529
+ list = list.filter((a) => a.id !== id);
530
+ }
531
+ await deps.io.writeJson(deps.feedbackFile, list);
532
+ result = {
533
+ ok: true, findingId: id,
534
+ human: entry.kind === 'feedback'
535
+ ? [`已把「${entry.plan.title}」标记为判错——下次体检起它会排在后面,但证据仍在。`, '撤销:该条目上的「撤销判错」。']
536
+ : [`「${entry.plan.title}」的判错标记已撤销,恢复正常评分。`],
537
+ };
490
538
  } else if (entry.kind === 'model-default') {
491
539
  const sel = typeof deps.getDefaultModel === 'function' ? deps.getDefaultModel() : null;
492
540
  if (!sel || typeof sel.saveSelection !== 'function') {
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-steward · client entry — v0.9.12 (model-mode conversational entry + L1 briefing;
2
+ * dsh-steward · client entry — v0.9.15 (model-mode conversational entry + L1 briefing;
3
3
  * ChatBar rebuilt as a mature chat composer: one rounded input bar, mode chip on the
4
4
  * left, round send button on the right, settings panel below the bar;
5
5
  * v0.7.5 collapse: a collapsed group lists EVERY peer as a two-line summary row
@@ -99,10 +99,10 @@ window.__ModuleLoader__.load({
99
99
  // finding → 动作直达映射(点按钮 = 切到动作中心并展开该动作)
100
100
  const ACTION_FOR = {
101
101
  'data-at-risk': 'backup',
102
+ 'backup-freshness': 'backup',
102
103
  'boot-crash-latest': 'disable',
103
104
  'disk-advice': 'clean',
104
105
  'knowledge': 'knowledge',
105
- 'model-health': 'model-default',
106
106
  };
107
107
 
108
108
  // v0.9.8: every request used to be able to hang forever. The server is allowed
@@ -119,7 +119,8 @@ window.__ModuleLoader__.load({
119
119
  function timeoutFor(action) {
120
120
  return SLOW_ACTIONS[action] || DEFAULT_TIMEOUT_MS;
121
121
  }
122
- async function call(action, extra) {
122
+ async function call(action, extra, attempt) {
123
+ const tries = typeof attempt === 'number' ? attempt : 0;
123
124
  const name = action || 'report';
124
125
  const budget = timeoutFor(name);
125
126
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
@@ -145,7 +146,24 @@ window.__ModuleLoader__.load({
145
146
  if (timer) clearTimeout(timer);
146
147
  }
147
148
  let data;
148
- try { data = await response.json(); } catch { throw new Error('HTTP ' + response.status); }
149
+ try { data = await response.json(); } catch { data = null; }
150
+ // v0.9.13: a 404 on our OWN route is a different failure from a 404 the
151
+ // server sent on purpose. It means this plugin's fiber is loaded (the
152
+ // panel rendered) but its route is not mounted. v0.9.14: there are TWO
153
+ // causes and the earlier wording only named the first — a host hot-reload
154
+ // disposes the old fiber, AND a cold boot can have webServer apply after
155
+ // this plugin, which is what the deferred registration now handles.
156
+ if (response.status === 404 && tries === 0) {
157
+ await new Promise((r) => setTimeout(r, 700));
158
+ return call(action, extra, tries + 1);
159
+ }
160
+ if (response.status === 404) {
161
+ throw new Error('接口没有挂上(HTTP 404)——面板加载了,但 /api/dsh-steward/ 这段路由没注册。'
162
+ + '两种常见原因:宿主热重载(改了插件文件,旧路由被销毁、新的没接上),'
163
+ + '或冷启动时 webServer 比插件晚就绪。'
164
+ + '在设置里把这个插件停用再启用一次通常就能恢复;也可以重启 DSH Web。'
165
+ + '恢复后 ping 会带 wiring.route,值是 registered 就说明路由真的挂上了。');
166
+ }
149
167
  if (!response.ok || !data || data.ok !== true) {
150
168
  throw new Error((data && data.error) || 'HTTP ' + response.status);
151
169
  }
@@ -247,6 +265,7 @@ window.__ModuleLoader__.load({
247
265
  },
248
266
  h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } },
249
267
  f.muted ? h(Badge, { color: C.faint }, '已静音') : h(Badge, { color: sev.color }, sev.label),
268
+ f.falsePositive ? h(Badge, { color: C.faint }, '已判错') : null,
250
269
  typeof f.score === 'number' ? h('span', {
251
270
  title: (f.scoreReasons || []).join(';') || '评分构成见体检报告',
252
271
  style: { fontSize: 11, color: C.faint, marginLeft: 6, cursor: 'help' },
@@ -266,6 +285,8 @@ window.__ModuleLoader__.load({
266
285
  props.onGo ? h(Btn, { onClick: props.onGo, primary: true, tone: C.text }, props.goLabel || '去处置 →') : null,
267
286
  props.onAck ? h(TinyBtn, { onClick: props.onAck }, '已知晓(静音此项)') : null,
268
287
  props.onUnack ? h(TinyBtn, { onClick: props.onUnack, tone: C.info }, '恢复显示此项') : null,
288
+ props.onFalsePositive ? h(TinyBtn, { onClick: props.onFalsePositive, tone: C.warning }, '这条判错了') : null,
289
+ props.onUnfalsePositive ? h(TinyBtn, { onClick: props.onUnfalsePositive, tone: C.info }, '撤销判错') : null,
269
290
  ),
270
291
  );
271
292
  }
@@ -393,16 +414,18 @@ window.__ModuleLoader__.load({
393
414
  return arr;
394
415
  };
395
416
  // 台账行必须说人话:内部 kind/error 码是机器字段,直接 dump 给用户等于没做呈现层。
396
- const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现' };
417
+ const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现', feedback: '标记一条判错', unfeedback: '撤销判错标记' };
397
418
  const ERR_ZH = {
398
419
  'unknown-or-expired-plan': '计划已过期或指纹不对——指纹是一次性的,请重新「预览计划」',
399
420
  'confirm-missing': '缺少授权确认——执行必须带 confirm:true',
400
421
  };
401
422
  const countsZh = (c) => (c ? ['严重 ' + (c.critical || 0), '留意 ' + (c.warning || 0), '提示 ' + (c.info || 0)].join(' · ') + ((c.muted || 0) > 0 ? ' · 静音 ' + c.muted : '') : '');
423
+ const tv = data && data.trendVerdict;
402
424
  return h('div', {},
403
425
  h('div', { style: { fontSize: 12, color: C.sub, lineHeight: 1.7, margin: '2px 0 10px', padding: '8px 10px', borderLeft: '2px solid ' + C.info, background: C.fill, borderRadius: 6 } },
404
426
  '这里是管家的操作台账:它经你授权动过系统的每一次记录都写在这里,包括被拒绝的尝试。', h('br'),
405
427
  '用途:核对「上次到底清了什么、什么时候、成没成」,以及看体检结论随时间是变好还是变坏。'),
428
+ tv && tv.text ? h('div', { style: { fontSize: 12, marginBottom: 10, padding: '7px 10px', borderRadius: 6, borderLeft: '2px solid ' + (tv.state === 'recovered' ? C.ok : tv.state === 'worsening' ? C.warning : C.info), background: C.fill, color: C.text } }, '趋势结论 · ' + tv.text) : null,
406
429
  h('div', { style: { marginBottom: 10, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
407
430
  h(Btn, { onClick: load, disabled: loading }, loading ? '读取中…' : '刷新台账'),
408
431
  // 不用原生 <select>:Windows 下拉弹层由系统绘制,无视宿主主题(白底浅字不可读)。分段按钮全部内联样式,跟随面板配色。
@@ -445,7 +468,7 @@ window.__ModuleLoader__.load({
445
468
  }
446
469
 
447
470
  // 模块级缓存:Panel 切走即卸载;报告与插件变更留在内存(刷新清空属预期)。
448
- const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null };
471
+ const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null, autoScanMinutes: 10, wiring: null };
449
472
  // v0.8.4:当前生效的入口重挂函数,由 apply() 填、由设置页调用。
450
473
  // 存在它就不需要「改完要 Ctrl+R」——那前提在桌面版里根本不成立。
451
474
  // ---------- 右下角临时弹窗(v0.9.1) ----------
@@ -506,6 +529,18 @@ window.__ModuleLoader__.load({
506
529
  toastedIds.add(f.id);
507
530
  pushToast(toastOf(f));
508
531
  }
532
+ // v0.9.15: 好转与连续恶化也主动说。结论由服务端算好人话,客户端只渲染。
533
+ const tv = report && report.trendVerdict;
534
+ if (tv && (tv.state === 'recovered' || (tv.state === 'worsening' && tv.worseningStreak >= 2))) {
535
+ const key = 'trend-' + tv.state + '-' + (report.generatedAt || '');
536
+ if (!toastedIds.has(key)) {
537
+ toastedIds.add(key);
538
+ pushToast({ kind: 'trend', findingId: null, actionKey: null,
539
+ severity: tv.state === 'recovered' ? 'ok' : 'info',
540
+ title: tv.state === 'recovered' ? '状况好转了' : '连续几次体检都在变差',
541
+ body: tv.text || '' });
542
+ }
543
+ }
509
544
  }
510
545
 
511
546
  // 弹窗住在 shell.overlay 里,不在面板的树里 —— 点击「去看看」需要一条通道
@@ -984,7 +1019,7 @@ window.__ModuleLoader__.load({
984
1019
  const gs = Array.isArray(res.groups) ? res.groups : [];
985
1020
  setGroups(gs);
986
1021
  setDraft(flatOf(gs));
987
- setSaved(res.savedAt ? "上次保存:" + String(res.savedAt).slice(0, 16).replace("T", " ") : "还没改过(全部是推荐默认值)");
1022
+ setSaved(res.savedAt ? "上次保存:" + localStamp(res.savedAt) + "(本地时间)" : "还没改过(全部是推荐默认值)");
988
1023
  } catch (e) { setError(e && e.message ? e.message : String(e)); }
989
1024
  finally { setLoading(false); }
990
1025
  }, []);
@@ -1088,7 +1123,7 @@ window.__ModuleLoader__.load({
1088
1123
  const timer = setTimeout(() => { setLeft((v) => v - 1); }, 1000);
1089
1124
  return () => clearTimeout(timer);
1090
1125
  }, [left, paused]);
1091
- const tone = t.severity === 'critical' ? C.critical : C.warning;
1126
+ const tone = t.severity === 'critical' ? C.critical : t.severity === 'ok' ? C.ok : t.severity === 'info' ? C.info : C.warning;
1092
1127
  return h('div', {
1093
1128
  // 鼠标移上去暂停计时:正在读的时候消失,比不弹更烦人。
1094
1129
  onMouseEnter: () => setPaused(true),
@@ -1101,7 +1136,7 @@ window.__ModuleLoader__.load({
1101
1136
  },
1102
1137
  },
1103
1138
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 } },
1104
- h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : '留意'),
1139
+ h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : t.severity === 'ok' ? '好转' : t.severity === 'info' ? '提醒' : '留意'),
1105
1140
  h('span', { style: { fontSize: 13, fontWeight: 600, flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t.title),
1106
1141
  h('button', {
1107
1142
  onClick: props.onDismiss, title: '关闭',
@@ -1110,15 +1145,15 @@ window.__ModuleLoader__.load({
1110
1145
  ),
1111
1146
  t.body ? h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 7, whiteSpace: 'pre-wrap' } }, t.body) : null,
1112
1147
  h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
1113
- h('button', {
1148
+ t.actionKey ? h('button', {
1114
1149
  onClick: () => { props.onOpen(); props.onDismiss(); },
1115
1150
  style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + tone, background: 'transparent', color: tone },
1116
- }, '去看看'),
1117
- h('button', {
1151
+ }, '去看看') : null,
1152
+ t.findingId ? h('button', {
1118
1153
  onClick: () => { props.onAck(); props.onDismiss(); },
1119
1154
  title: '静音这条,直到有效期结束',
1120
1155
  style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.sub },
1121
- }, '已知晓'),
1156
+ }, '已知晓') : null,
1122
1157
  h('span', { style: { flex: '1 1 auto' } }),
1123
1158
  h('span', { style: { fontSize: 10, color: C.faint, flex: '0 0 auto' } }, paused ? '暂停' : left + 's'),
1124
1159
  ),
@@ -1157,6 +1192,8 @@ window.__ModuleLoader__.load({
1157
1192
  try {
1158
1193
  const data = await call('report');
1159
1194
  if (!alive) return;
1195
+ if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
1196
+ if (data && data.wiring) cache.wiring = data.wiring;
1160
1197
  if (data && data.report) { cache.report = data.report; evaluateToasts(data.report); }
1161
1198
  } catch { /* 采集失败不弹任何东西——宁可不提醒,也不误报 */ }
1162
1199
  })();
@@ -1200,6 +1237,8 @@ window.__ModuleLoader__.load({
1200
1237
  setLoading(true); setError('');
1201
1238
  try {
1202
1239
  const data = await call('report');
1240
+ if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
1241
+ if (data && data.wiring) cache.wiring = data.wiring;
1203
1242
  try { publishReport(data && data.report); } catch { /* noop */ }
1204
1243
  cache.report = data.report || null;
1205
1244
  cache.error = '';
@@ -1215,12 +1254,13 @@ window.__ModuleLoader__.load({
1215
1254
  try { setBundles(await call('bundles')); } catch { setBundles(null); }
1216
1255
  }, []);
1217
1256
 
1218
- // 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过 10 分钟→后台刷新。
1257
+ // 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过设置的分钟数→后台刷新。
1219
1258
  // 先例:disk-cleaner 面板同样挂载即扫,同一 GUI 正常运行——二分定案证明卡死元凶不是自动扫描。
1220
1259
  useEffect(() => {
1221
1260
  if (!cache.report) { void scan(); return; }
1222
1261
  const age = Date.now() - Date.parse(String(cache.report.generatedAt || ''));
1223
- if (Number.isFinite(age) && age > 10 * 60 * 1000) void scan();
1262
+ const maxAge = (Number.isFinite(cache.autoScanMinutes) ? cache.autoScanMinutes : 10) * 60 * 1000;
1263
+ if (Number.isFinite(age) && age > maxAge) void scan();
1224
1264
  }, [scan]);
1225
1265
 
1226
1266
  const crashFinding = report && Array.isArray(report.findings)
@@ -1252,6 +1292,16 @@ window.__ModuleLoader__.load({
1252
1292
  await scan();
1253
1293
  } catch (e) { setError(e && e.message ? e.message : String(e)); }
1254
1294
  }, [scan]);
1295
+ const falsePositiveIt = useCallback(async (finding, undo) => {
1296
+ try {
1297
+ const p = await call('plan', { kind: undo ? 'unfeedback' : 'feedback', id: finding.id });
1298
+ if (p.ok === false) throw new Error(p.error || 'plan 失败');
1299
+ await call('execute', { planHash: p.planHash, confirm: true });
1300
+ await scan();
1301
+ } catch (e) { setError(e && e.message ? e.message : String(e)); }
1302
+ }, [scan]);
1303
+ const falsePositiveRef = useRef(falsePositiveIt);
1304
+ falsePositiveRef.current = falsePositiveIt;
1255
1305
 
1256
1306
  // 把面板内的能力交给 overlay 里的弹窗宿主。
1257
1307
  React.useEffect(() => {
@@ -1298,8 +1348,10 @@ window.__ModuleLoader__.load({
1298
1348
  finding: f,
1299
1349
  onAck: !f.muted ? () => ackIt(f) : null,
1300
1350
  onUnack: f.muted ? () => unackIt(f) : null,
1351
+ onFalsePositive: !f.falsePositive ? () => falsePositiveIt(f, false) : null,
1352
+ onUnfalsePositive: f.falsePositive ? () => falsePositiveIt(f, true) : null,
1301
1353
  onGo: ACTION_FOR[f.id] && !f.muted && f.severity !== 'info' ? () => jumpToAction(ACTION_FOR[f.id]) : null,
1302
- goLabel: f.id === 'data-at-risk' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
1354
+ goLabel: f.id === 'data-at-risk' || f.id === 'backup-freshness' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
1303
1355
  });
1304
1356
  const groupBlock = (label, color, list) => {
1305
1357
  if (!list.length) return null;
@@ -1405,6 +1457,10 @@ window.__ModuleLoader__.load({
1405
1457
  h('div', {}, '动作执行全部写入 audit.jsonl,可在「审计趋势」回看。'),
1406
1458
  h('div', { style: { marginTop: 4 } }, '依赖清单与降级行为、设计取舍见插件目录 README(dsh-steward/README.md)。')) : null,
1407
1459
  report ? h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 8 } }, '生成时间 · ' + localStamp(report.generatedAt) + (agoText(report.generatedAt) ? '(' + agoText(report.generatedAt) + ')' : '')) : null,
1460
+ report && cache.wiring ? h('div', { style: { fontSize: 10.5, marginTop: 4, color: cache.wiring.route === 'registered' ? C.faint : C.critical } },
1461
+ cache.wiring.route === 'registered'
1462
+ ? '接口已挂上 · 路由与工具随服务就绪后注册'
1463
+ : '接口未挂上(' + String(cache.wiring.route) + ')——停用再启用本插件,或重启 DSH Web') : null,
1408
1464
  ) : null,
1409
1465
 
1410
1466
  tab === 'actions' ? h('div', { style: { marginTop: 12 } },
package/lib/index.js CHANGED
@@ -65,6 +65,48 @@ function getService(ctx, key) {
65
65
  return null;
66
66
  }
67
67
 
68
+ // What the deferred wiring actually achieved. Reported by ping so "the panel
69
+ // draws but every request 404s" stops being indistinguishable from "fine".
70
+ const wiring = { route: 'pending', via: {} };
71
+
72
+ /**
73
+ * v0.9.14: run `fn` once the named services are live.
74
+ *
75
+ * getService() only sees services that are ALREADY active at the moment of the
76
+ * call. On a cold boot `webServer` and `tools` activate after this plugin, so a
77
+ * one-shot lookup returns null and every registration is skipped — silently,
78
+ * because each block is wrapped in a fail-soft try/catch. The symptom is exactly
79
+ * what was reported: the panel draws itself (its client bundle comes from the
80
+ * manifest) while the route and both tools were never registered, so each
81
+ * request answered 404 and every dsh_steward_* tool was absent.
82
+ *
83
+ * ctx.inject() is the documented optional-access path: it defers the callback
84
+ * until the services exist. Unlike a hard `export const inject` it does not make
85
+ * this bundle a boot dependency.
86
+ */
87
+ function whenService(ctx, keys, fn) {
88
+ let injected = false;
89
+ try {
90
+ if (typeof ctx.inject === 'function') {
91
+ ctx.inject(keys, (scoped) => {
92
+ wiring.via[keys.join(',')] = 'inject';
93
+ try { fn(scoped || ctx); } catch (e) {
94
+ try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] deferred setup failed (' + keys.join(',') + '): ' + String(e && e.message || e)); } catch { /* noop */ }
95
+ }
96
+ });
97
+ injected = true;
98
+ }
99
+ } catch (e) {
100
+ try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] ctx.inject unavailable: ' + String(e && e.message || e)); } catch { /* noop */ }
101
+ }
102
+ if (injected) return true;
103
+ // Fallback for a ctx without inject(): the old best-effort one-shot lookup.
104
+ try {
105
+ if (getService(ctx, keys[0])) { wiring.via[keys.join(',')] = 'one-shot'; fn(ctx); return true; }
106
+ } catch { /* nothing live yet — stays inert, as before */ }
107
+ return false;
108
+ }
109
+
68
110
  function sendJson(res, status, value) {
69
111
  try {
70
112
  const body = Buffer.from(JSON.stringify(value));
@@ -170,6 +212,7 @@ function renderReportText(report) {
170
212
  lines.push(report.summary.headline);
171
213
  lines.push('');
172
214
  lines.push(`生成时间: ${report.generatedAt}(UTC) | 状态: ${report.status} | 计数: critical=${report.summary.counts.critical} warning=${report.summary.counts.warning} info=${report.summary.counts.info}`);
215
+ if (report.trendVerdict && report.trendVerdict.text) lines.push(`趋势: ${report.trendVerdict.text}`);
173
216
  lines.push('');
174
217
  for (const f of report.findings) {
175
218
  lines.push(`## [${f.severity.toUpperCase()}] ${f.title}`);
@@ -228,6 +271,7 @@ export function apply(ctx) {
228
271
  configBaseline: path.join(dataDir(), 'config-baseline.json'),
229
272
  settings: path.join(dataDir(), 'settings.json'),
230
273
  modelProbes: path.join(dataDir(), 'model-probes.json'),
274
+ feedback: path.join(dataDir(), 'feedback.json'),
231
275
  };
232
276
  // v0.8.0 设置:内存缓存 + 落盘。读不到/坏了 → 全默认(旧行为),绝不拦住体检。
233
277
  let settingsCache = null;
@@ -333,6 +377,7 @@ export function apply(ctx) {
333
377
  trendFile: files.trend,
334
378
  knowledgeFile: files.knowledge,
335
379
  ackedFile: files.acked,
380
+ feedbackFile: files.feedback,
336
381
  });
337
382
 
338
383
  // ---------- v0.9.1 模型健康 ----------
@@ -451,7 +496,7 @@ export function apply(ctx) {
451
496
  }
452
497
 
453
498
  async function run(action, payload) {
454
- if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true };
499
+ if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring } };
455
500
  // v0.8.0 settings: 只读设置页需要的一切(值 + 范围 + 为什么),以及一次写入。
456
501
  if (action === 'settings') {
457
502
  await loadSettings();
@@ -466,6 +511,8 @@ export function apply(ctx) {
466
511
  if (action === 'report') {
467
512
  let acked = [];
468
513
  try { acked = (await io.readJson(files.acked)) || []; } catch { acked = []; }
514
+ let feedback = [];
515
+ try { feedback = (await io.readJson(files.feedback)) || []; } catch { feedback = []; }
469
516
  const knowledge = await loadKnowledge();
470
517
  // ④ v0.7.6 config drift: compare against the baseline recorded on the
471
518
  // previous scan, then renew it. The baseline is ours (data dir), never the
@@ -486,13 +533,13 @@ export function apply(ctx) {
486
533
  if (saved && saved.report && typeof saved.report === 'object') previous = saved.report;
487
534
  } catch { previous = null; }
488
535
  const modelHealth = await modelHealthForReport();
489
- lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
536
+ lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], feedback: Array.isArray(feedback) ? feedback : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
490
537
  // Persist best-effort; a failed persist never fails the report itself.
491
538
  io.writeJson(files.lastReport, { report: lastReport }).catch(() => {});
492
539
  if (Array.isArray(lastReport.config) && lastReport.config.length > 0) {
493
540
  io.writeJson(files.configBaseline, { at: lastReport.generatedAt, entries: lastReport.config }).catch(() => {});
494
541
  }
495
- return { report: lastReport, text: renderReportText(lastReport) };
542
+ return { report: lastReport, text: renderReportText(lastReport), autoScanMinutes: settingValue(currentSettings(), 'ui.autoScanMinutes'), wiring: { ...wiring } };
496
543
  }
497
544
  if (action === 'plan') return await hub.plan(payload.kind, payload);
498
545
  if (action === 'execute') {
@@ -614,7 +661,7 @@ export function apply(ctx) {
614
661
  } catch { auditLines = []; }
615
662
  let trend = [];
616
663
  try { const t = await io.readJson(files.trend); if (Array.isArray(t)) trend = t.slice(-20); } catch { trend = []; }
617
- return { ok: true, audit: auditLines, trend };
664
+ return { ok: true, audit: auditLines, trend, trendVerdict: lastReport && lastReport.trendVerdict ? lastReport.trendVerdict : null };
618
665
  }
619
666
  return { ok: false, error: `unknown action: ${String(action)}` };
620
667
  }
@@ -643,7 +690,7 @@ export function apply(ctx) {
643
690
  // @changfenhuang/dsh-genui/lib/index.js:7772 ({name, order, text}, order via
644
691
  // getSectionOrder). Guarded: if the service or getSectionOrder is missing we
645
692
  // skip entirely (探针先行 red line: never guess, never endanger boot).
646
- try {
693
+ whenService(ctx, ['systemPrompt'], () => {
647
694
  const sp = getService(ctx, 'systemPrompt');
648
695
  if (sp && typeof sp.section === 'function' && typeof sp.getSectionOrder === 'function') {
649
696
  ctx.effect(() => {
@@ -659,15 +706,17 @@ export function apply(ctx) {
659
706
  }
660
707
  }, 'dsh-steward: prompt section');
661
708
  }
662
- } catch { /* systemPrompt lookup failed — passive mode, panel red dot only */ }
709
+ });
663
710
 
664
- // 2) Host prefix route (panel + same-origin callers).
665
- try {
711
+ // 2) Host prefix route (panel + same-origin callers). Deferred until the
712
+ // service exists: on a cold boot webServer activates after this plugin, and a
713
+ // one-shot lookup silently registered nothing (the reported 404).
714
+ whenService(ctx, ['webServer'], () => {
666
715
  const webServer = getService(ctx, 'webServer');
667
716
  if (webServer && typeof webServer.register === 'function') {
668
717
  ctx.effect(() => {
669
718
  try {
670
- return webServer.register({
719
+ const dispose = webServer.register({
671
720
  kind: 'prefix',
672
721
  path: ROUTE_PREFIX,
673
722
  handler: async (req, res) => {
@@ -688,17 +737,20 @@ export function apply(ctx) {
688
737
  }
689
738
  },
690
739
  });
740
+ wiring.route = 'registered';
741
+ return dispose;
691
742
  } catch (e) {
743
+ wiring.route = 'failed: ' + String(e && e.message || e);
692
744
  // Contained: a route registration failure must NOT fail the boot fiber.
693
745
  try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] route registration skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
694
746
  return undefined;
695
747
  }
696
748
  }, 'dsh-steward: routes');
697
749
  }
698
- } catch { /* webServer lookup failed — ignore */ }
750
+ });
699
751
 
700
- // 3) Read-only health tool (kept from v0.1).
701
- try {
752
+ // 3) Read-only health tool (kept from v0.1). Same cold-boot race, same fix.
753
+ whenService(ctx, ['tools'], () => {
702
754
  const tools = getService(ctx, 'tools');
703
755
  if (tools && typeof tools.register === 'function') {
704
756
  ctx.effect(() => {
@@ -774,7 +826,7 @@ export function apply(ctx) {
774
826
  }
775
827
  }, 'dsh-steward: act tool');
776
828
  }
777
- } catch { /* tools lookup failed — ignore */ }
829
+ });
778
830
  }
779
831
 
780
832
  export default apply;
package/lib/report.js CHANGED
@@ -575,7 +575,7 @@ function scoreFindings(findings, ctx) {
575
575
  // A stale crash does NOT count: its disable action is blocked by the
576
576
  // attribution gate, so promising a shortcut there would be dishonest.
577
577
  const gateBlocked = f.id === 'boot-crash-latest' && f.data && f.data.stale === true;
578
- if (ACTIONABLE_IDS.has(f.id) && !f.muted && !gateBlocked) {
578
+ if (ACTIONABLE_IDS.has(f.id) && !f.muted && !f.falsePositive && !gateBlocked) {
579
579
  score += 15; reasons.push('有直达处置动作 +15');
580
580
  } else if (gateBlocked) {
581
581
  reasons.push('处置被归因门封锁,不计可直达');
@@ -606,6 +606,7 @@ function scoreFindings(findings, ctx) {
606
606
  if (kinds > 0) mag += Math.min(10, kinds * 4);
607
607
  }
608
608
  if (mag) { score += mag; reasons.push('体量/条数 +' + mag); }
609
+ if (f.falsePositive) { score -= 40; reasons.push('用户标记判错 -40'); }
609
610
 
610
611
  score = Math.max(0, Math.min(100, Math.round(score)));
611
612
  f.score = score;
@@ -857,6 +858,35 @@ function collectClientIdentity(paths, io, errors) {
857
858
  return { entries, sameReal, drifted, collisions, unreadable, withoutId, scanned };
858
859
  }
859
860
 
861
+ /**
862
+ * v0.9.15: turn 'better / worse / flat' into one Chinese sentence on the
863
+ * server. The client only renders it (USER.md: GUI is for humans, the host
864
+ * computes the words). previous is last run's persisted report; current is
865
+ * this run's { level, counts }. worseningStreak rides along in the report so
866
+ * consecutive declines can be counted without a second data store.
867
+ */
868
+ export function summarizeTrend(previous, current) {
869
+ const cur = current || {};
870
+ const rank = (lv) => (lv === 'critical' ? 3 : lv === 'warning' ? 2 : 0);
871
+ const bad = (c) => (Number(c && c.critical) || 0) * 3 + (Number(c && c.warning) || 0) + rank(cur.level) * 2;
872
+ const curBad = bad(cur.counts);
873
+ const prev = previous && previous.summary ? previous.summary : null;
874
+ if (!prev) return { state: 'stable', worseningStreak: 0, text: '这是第一次体检,还没有可比的上一轮结论。' };
875
+ const prevBad = (Number(prev.counts && prev.counts.critical) || 0) * 3 + (Number(prev.counts && prev.counts.warning) || 0) + rank(prev.level) * 2;
876
+ const prevStreak = Number(previous.trendVerdict && previous.trendVerdict.worseningStreak) || 0;
877
+ if (curBad < prevBad) {
878
+ return { state: 'recovered', worseningStreak: 0, text: '比上一次体检好转了:风险项减少或严重项已消除。' };
879
+ }
880
+ if (curBad > prevBad) {
881
+ const streak = prevStreak + 1;
882
+ return { state: 'worsening', worseningStreak: streak,
883
+ text: streak >= 2
884
+ ? '连续 ' + streak + ' 次体检都在变差,值得回头看审计趋势里这几次改了什么。'
885
+ : '比上一次体检多了风险项,先看看新出现的是哪一条。' };
886
+ }
887
+ return { state: 'stable', worseningStreak: 0, text: '与上一次体检相比没有明显变化。' };
888
+ }
889
+
860
890
  export function collectReport(options = {}) {
861
891
  const now = options.now || new Date();
862
892
  const env = options.env || process.env;
@@ -1644,9 +1674,16 @@ export function collectReport(options = {}) {
1644
1674
  .map((a) => (a && typeof a === 'object' ? a.id : a))
1645
1675
  .filter((x) => typeof x === 'string'),
1646
1676
  );
1677
+ const feedbackRaw = Array.isArray(options.feedback) ? options.feedback : [];
1678
+ const feedbackIds = new Set(
1679
+ feedbackRaw
1680
+ .filter((a) => a && typeof a === 'object' && typeof a.id === 'string')
1681
+ .map((a) => a.id),
1682
+ );
1647
1683
  let mutedCount = 0;
1648
1684
  for (const f of findings) {
1649
1685
  f.muted = ackedIds.has(f.id);
1686
+ f.falsePositive = feedbackIds.has(f.id);
1650
1687
  if (f.muted) mutedCount += 1;
1651
1688
  }
1652
1689
 
@@ -1669,6 +1706,7 @@ export function collectReport(options = {}) {
1669
1706
  : counts.warning > 0
1670
1707
  ? '有需要留意的风险项'
1671
1708
  : '当前未发现异常,以下为常备建议';
1709
+ const trendVerdict = summarizeTrend(options.previous, { level, counts });
1672
1710
 
1673
1711
  return {
1674
1712
  generatedAt: now.toISOString ? now.toISOString() : String(now),
@@ -1680,8 +1718,9 @@ export function collectReport(options = {}) {
1680
1718
  updates: updateInfo,
1681
1719
  coverage,
1682
1720
  status: errors.length > 0 ? 'degraded' : 'ok',
1683
- summary: { level, headline, counts },
1684
1721
  findings,
1722
+ summary: { level, headline, counts },
1723
+ trendVerdict,
1685
1724
  limitations: [
1686
1725
  '只读:不删文件、不代跑提权命令、不自动拉起/重启、不改启动流程。',
1687
1726
  '不重复实现磁盘扫描 / 备份 / 归档 / 记忆,只指向现成能力。',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sidady/dsh-steward",
3
- "version": "0.9.12",
3
+ "version": "0.9.15",
4
4
  "type": "module",
5
5
  "description": "Guardian for DeepSeek Harness: a read-only health check (boot crashes, backup freshness, config drift, workspace growth, plugin updates, host-version compat, model availability) plus an explicitly authorized action layer (plan → single-use fingerprint → confirm → audit) covering cache cleaning, encrypted volatile-data backups, plugin enable/disable, knowledge capture, finding mute, and switching the default model.",
6
6
  "keywords": [
package/smoke.mjs CHANGED
@@ -1329,8 +1329,9 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
1329
1329
  const clientLines = clientSrc.split("\n");
1330
1330
  // 审计/趋势里存的是 UTC ISO;切片或把 T 换成空格都会原样把 UTC 贴给用户
1331
1331
  // (东八区差 8 小时)。本地化必须走 localStamp,不能用字符串处理假装。
1332
- const TQ = String.fromCharCode(39);
1333
- const naive = clientLines.filter((l) => l.includes(".replace(") && l.includes(TQ + "T" + TQ + ", " + TQ + " " + TQ));
1332
+ const TQ = String.fromCharCode(39), TDQ = String.fromCharCode(34);
1333
+ const swap = (q) => q + "T" + q + ", " + q + " " + q;
1334
+ const naive = clientLines.filter((l) => l.includes(".replace(") && (l.includes(swap(TQ)) || l.includes(swap(TDQ))));
1334
1335
  check("no render path formats a timestamp by swapping the ISO T", naive.length === 0, naive.map((l) => l.trim().slice(0, 70)).join(" | "));
1335
1336
  check("the local-time formatter exists", clientSrc.includes("function localStamp("));
1336
1337
  const uses = (clientSrc.match(/localStamp\(/g) || []).length;
@@ -1376,6 +1377,103 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
1376
1377
  check("washes and rings derive from an accent", mixUses >= 6, String(mixUses));
1377
1378
  check("the only saturated tokens are the six accents", /accentStability/.test(src) && /accentInfo/.test(src));
1378
1379
  }
1380
+
1381
+ // — v0.9.13: 面板自己的路由没挂上时,不许只说「HTTP 404」 —
1382
+ {
1383
+ // 真机形态:改了插件源码触发宿主热重载,旧 fiber 被销毁、新 fiber 的路由
1384
+ // 没接上,面板只剩一行「读取失败: HTTP 404」。用户无法判断这是接口没挂、
1385
+ // 还是请求本身出错。这一组钉住三件事:认得出来、说得出怎么办、只重试一次。
1386
+ const clientSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1387
+ const callBody = clientSrc.slice(clientSrc.indexOf('async function call('), clientSrc.indexOf('function PanelIcon'));
1388
+
1389
+ check('a 404 on our own route is distinguished from a plain HTTP failure',
1390
+ /response\.status === 404/.test(callBody) && /接口没有挂上/.test(callBody));
1391
+ check('the 404 message names the hot-reload cause and the way out',
1392
+ /热重载/.test(callBody) && /停用再启用|重启 DSH Web/.test(callBody));
1393
+ check('the retry is bounded — one attempt, then it reports',
1394
+ /tries === 0/.test(callBody) && /call\(action, extra, tries \+ 1\)/.test(callBody));
1395
+ check('a generic failure still reports the server error verbatim',
1396
+ /\(data && data\.error\) \|\| 'HTTP ' \+ response\.status/.test(callBody));
1397
+ // 反向判据:不许把任何非 2xx 都说成「热重载」,那会把真正的服务端错误藏掉。
1398
+ check('the hot-reload wording is gated on 404, not on every non-2xx',
1399
+ !/接口没有挂上/.test(callBody.split('response.status === 404')[0]));
1400
+ }
1401
+ // — v0.9.14: 冷启动竞态 —— 服务没就绪时不许把注册整段跳过 —
1402
+ {
1403
+ // 真机形态:重启(不是热重载)后面板照样 404。原因不是路由写错了,而是
1404
+ // apply() 里用 ctx.get('webServer') 一次性探测:冷启动时 webServer 与 tools
1405
+ // 都比本插件晚 apply,那一次探测拿到 null,于是 if (webServer && ...) 整段
1406
+ // 跳过,路由与两个工具都没注册 —— 而每段外面都包了 fail-soft try/catch,
1407
+ // 所以只 warn 一行,面板与工具都静默消失。
1408
+ const idxSrc = fs.readFileSync(path.join(here, 'lib', 'index.js'), 'utf8');
1409
+
1410
+ check('the plugin defers to ctx.inject instead of a one-shot lookup',
1411
+ /ctx\.inject\(keys,/.test(idxSrc) && /function whenService\(/.test(idxSrc));
1412
+ check('every service-backed registration goes through whenService',
1413
+ (idxSrc.match(/whenService\(ctx, \['/g) || []).length >= 3);
1414
+ // 反向判据:旧的裸探测写法必须彻底消失,否则冷启动仍会静默跳过。
1415
+ check('no registration is left behind a bare try/getService probe',
1416
+ !/try \{\n\s*const webServer = getService\(/.test(idxSrc)
1417
+ && !/try \{\n\s*const tools = getService\(/.test(idxSrc)
1418
+ && !/try \{\n\s*const sp = getService\(/.test(idxSrc));
1419
+ // 静默失败是这次真正的病因:说得出「挂上了没有」比「应该挂上」有用。
1420
+ check('the wiring outcome is recorded, not just logged',
1421
+ /wiring\.route = 'registered'/.test(idxSrc) && /wiring\.route = 'failed: '/.test(idxSrc));
1422
+ check('ping reports the wiring outcome so a 404 is diagnosable',
1423
+ /wiring: \{ \.\.\.wiring \}/.test(idxSrc));
1424
+ // 走的是哪条路也要记:inject 与一次性探测在冷启动下行为完全不同,
1425
+ // 只看 route=registered 无法区分「真的等了」和「碰巧那次就绪了」。
1426
+ check('the wiring path records whether inject was used',
1427
+ /wiring\.via\[keys\.join\(','\)\] = 'inject'/.test(idxSrc)
1428
+ && /wiring\.via\[keys\.join\(','\)\] = 'one-shot'/.test(idxSrc));
1429
+ }
1430
+
1431
+
1432
+ // — v0.9.15: 职能缺口补齐(P0 修真 bug / P1 误报反馈 / P2 趋势与直达)—
1433
+ {
1434
+ const clientSrc = fs.readFileSync(path.join(here, "lib", "client.js"), "utf8");
1435
+ const indexSrc = fs.readFileSync(path.join(here, "lib", "index.js"), "utf8");
1436
+ const actionsSrc = fs.readFileSync(path.join(here, "lib", "actions.js"), "utf8");
1437
+ const reportSrc = fs.readFileSync(path.join(here, "lib", "report.js"), "utf8");
1438
+ const clientLines = clientSrc.split(String.fromCharCode(10));
1439
+
1440
+ // P0-a: 保存时间的本地化不许再用「把 T 换成空格」的写法,两种引号都查。
1441
+ const SQ = String.fromCharCode(39), DQ = String.fromCharCode(34);
1442
+ const swaps = [SQ, DQ].map((q) => q + "T" + q + ", " + q + " " + q);
1443
+ const naive = clientLines.filter((l) => l.includes(".replace(") && swaps.some((s) => l.includes(s)));
1444
+ check("P0-a no ISO-T swap survives in either quote style", naive.length === 0, naive.map((l) => l.trim().slice(0, 60)).join(" | "));
1445
+ const savedLine = clientLines.find((l) => l.includes("setSaved(") && l.includes("res.savedAt"));
1446
+ check("P0-a the saved-at display calls localStamp", !!savedLine && savedLine.includes("localStamp("));
1447
+
1448
+ // P0-b: autoScanMinutes 必须是活设置,不是写死的 10 分钟。
1449
+ check("P0-b the client reads autoScanMinutes from the report payload", clientSrc.includes("cache.autoScanMinutes"));
1450
+ check("P0-b the hardcoded 10-minute threshold is gone", !clientSrc.includes("10 * 60 * 1000"));
1451
+ check("P0-b the host sends autoScanMinutes from settings", indexSrc.includes("autoScanMinutes: settingValue(currentSettings(), " + SQ + "ui.autoScanMinutes" + SQ + ")"));
1452
+
1453
+ // P1: 误报反馈是一个有数据文件、可 plan/execute、能影响评分、可撤销的真动作。
1454
+ check("P1 feedback has its own data file", indexSrc.includes("feedback: path.join(dataDir(), " + SQ + "feedback.json" + SQ + ")"));
1455
+ check("P1 plan handles feedback and unfeedback", actionsSrc.includes("kind === " + SQ + "feedback" + SQ + " || kind === " + SQ + "unfeedback" + SQ));
1456
+ check("P1 execute persists the feedback list", actionsSrc.includes("writeJson(deps.feedbackFile, list)"));
1457
+ check("P1 report marks falsePositive from options.feedback", reportSrc.includes("f.falsePositive = feedbackIds.has(f.id)"));
1458
+ check("P1 a false positive loses the actionable bonus", reportSrc.includes("&& !f.falsePositive && !gateBlocked"));
1459
+ check("P1 the score states the false-positive penalty", reportSrc.includes("用户标记判错 -40"));
1460
+ check("P1 the panel offers a 判错 button", clientSrc.includes("这条判错了"));
1461
+
1462
+ // P2-a: 趋势结论由服务端算好人话,恢复/连续恶化会主动说。
1463
+ check("P2-a summarizeTrend is exported from report.js", reportSrc.includes("export function summarizeTrend("));
1464
+ check("P2-a collectReport returns a trendVerdict", reportSrc.includes("trendVerdict,"));
1465
+ check("P2-a the text report states the trend", indexSrc.includes("趋势: ${report.trendVerdict.text}"));
1466
+ check("P2-a toasts fire on recovery",
1467
+ clientSrc.includes("tv.state === " + SQ + "recovered" + SQ + " ||") && clientSrc.includes("pushToast({ kind: " + SQ + "trend" + SQ));
1468
+ check("P2-a ToastCard can paint an ok tone", clientSrc.includes("t.severity === " + SQ + "ok" + SQ + " ? C.ok"));
1469
+ check("P2-a AuditTab prints the trend verdict", clientSrc.includes("趋势结论 · "));
1470
+
1471
+ // P2-b: 不假造直达动作;运行期自检结果要给用户看见。
1472
+ check("P2-b the phantom model-default shortcut is gone", !clientSrc.includes("" + SQ + "model-health" + SQ + ": " + SQ + "model-default" + SQ) && clientSrc.includes("" + SQ + "backup-freshness" + SQ + ": " + SQ + "backup" + SQ));
1473
+ check("P2-b the host sends wiring with the report", indexSrc.includes("wiring: { ...wiring }"));
1474
+ check("P2-b the panel shows the wiring outcome", clientSrc.includes("接口已挂上"));
1475
+ }
1476
+
1379
1477
  console.log('');
1380
1478
  console.log(`RESULT: ${pass} passed, ${fail} failed`);
1381
1479
  process.exit(fail === 0 ? 0 : 1);