@sidady/dsh-steward 0.9.14 → 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 +88 -25
- package/README.md +40 -2
- package/lib/actions.js +49 -1
- package/lib/client.js +52 -14
- package/lib/index.js +8 -3
- package/lib/report.js +41 -2
- package/package.json +1 -1
- package/smoke.mjs +49 -2
package/ACCEPTANCE.md
CHANGED
|
@@ -37,38 +37,67 @@
|
|
|
37
37
|
> 还有一条**静态判据**的用法:像「渲染处不许把 ISO 的 T 换成空格」这类规约(v0.9.11),
|
|
38
38
|
> 与其等它在界面上出错,不如直接扫源码——但静态判据必须**验证过会红**,否则它只是装饰。
|
|
39
39
|
|
|
40
|
-
> **2026-11-
|
|
41
|
-
>
|
|
42
|
-
>
|
|
43
|
-
>
|
|
44
|
-
>
|
|
45
|
-
>
|
|
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
|
-
|
|
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.
|
|
52
|
-
|
|
|
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
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
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` `
|
|
62
|
-
| 设置 | `
|
|
63
|
-
| 客户端包 | 38
|
|
64
|
-
| 默认模型 |
|
|
65
|
-
| 加密备份 | 2 份,最新 `dsh-config-2026-11-09-1749ba.zip`(
|
|
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
|
-
>
|
|
71
|
-
>
|
|
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,仍全绿(基线
|
|
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,6 +1,44 @@
|
|
|
1
1
|
# dsh-steward — 智能管家(体检 + 授权执行)插件
|
|
2
2
|
|
|
3
|
-
**状态:v0.9.
|
|
3
|
+
**状态:v0.9.15 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项)**
|
|
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 生成或代输。
|
|
4
42
|
|
|
5
43
|
## v0.9.14(冷启动竞态:路由与工具都注册了,只是没在最合适的那一刻)
|
|
6
44
|
|
|
@@ -319,7 +357,7 @@ v0.7 给这个输入框加了「模型理解」:可选的第二通话方式,
|
|
|
319
357
|
- `lib/brain.js`:v0.7 模型理解层。把一句话交给宿主 `llm` 服务,把模型输出归一成与 `intent.js` **完全同形状**的意图;JSON 抽取容错(容忍围栏与前后废话),各字段走白名单校验,任何不合规都降级为 clarify。**只翻译,不执行。**
|
|
320
358
|
- `lib/index.js`:host(路由 + `dsh_steward_report` / `dsh_steward_act` 两个工具 + 事件订阅 + boot 后自动体检 + systemPrompt 注入)。
|
|
321
359
|
- `lib/client.js`:client(`__ModuleLoader__.load` + 三 Tab 面板 + 动作卡 + 侧栏/右侧栏入口,全内联样式)。
|
|
322
|
-
- `smoke.mjs`:本地验证(不启动宿主),
|
|
360
|
+
- `smoke.mjs`:本地验证(不启动宿主),292 项断言(含打包自检、时间戳、配色与「延迟注册」四类静态规约判据)。
|
|
323
361
|
|
|
324
362
|
## 本地验证(不装、不重启)
|
|
325
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.
|
|
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
|
|
@@ -265,6 +265,7 @@ window.__ModuleLoader__.load({
|
|
|
265
265
|
},
|
|
266
266
|
h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } },
|
|
267
267
|
f.muted ? h(Badge, { color: C.faint }, '已静音') : h(Badge, { color: sev.color }, sev.label),
|
|
268
|
+
f.falsePositive ? h(Badge, { color: C.faint }, '已判错') : null,
|
|
268
269
|
typeof f.score === 'number' ? h('span', {
|
|
269
270
|
title: (f.scoreReasons || []).join(';') || '评分构成见体检报告',
|
|
270
271
|
style: { fontSize: 11, color: C.faint, marginLeft: 6, cursor: 'help' },
|
|
@@ -284,6 +285,8 @@ window.__ModuleLoader__.load({
|
|
|
284
285
|
props.onGo ? h(Btn, { onClick: props.onGo, primary: true, tone: C.text }, props.goLabel || '去处置 →') : null,
|
|
285
286
|
props.onAck ? h(TinyBtn, { onClick: props.onAck }, '已知晓(静音此项)') : null,
|
|
286
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,
|
|
287
290
|
),
|
|
288
291
|
);
|
|
289
292
|
}
|
|
@@ -411,16 +414,18 @@ window.__ModuleLoader__.load({
|
|
|
411
414
|
return arr;
|
|
412
415
|
};
|
|
413
416
|
// 台账行必须说人话:内部 kind/error 码是机器字段,直接 dump 给用户等于没做呈现层。
|
|
414
|
-
const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现' };
|
|
417
|
+
const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现', feedback: '标记一条判错', unfeedback: '撤销判错标记' };
|
|
415
418
|
const ERR_ZH = {
|
|
416
419
|
'unknown-or-expired-plan': '计划已过期或指纹不对——指纹是一次性的,请重新「预览计划」',
|
|
417
420
|
'confirm-missing': '缺少授权确认——执行必须带 confirm:true',
|
|
418
421
|
};
|
|
419
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;
|
|
420
424
|
return h('div', {},
|
|
421
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 } },
|
|
422
426
|
'这里是管家的操作台账:它经你授权动过系统的每一次记录都写在这里,包括被拒绝的尝试。', h('br'),
|
|
423
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,
|
|
424
429
|
h('div', { style: { marginBottom: 10, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
|
|
425
430
|
h(Btn, { onClick: load, disabled: loading }, loading ? '读取中…' : '刷新台账'),
|
|
426
431
|
// 不用原生 <select>:Windows 下拉弹层由系统绘制,无视宿主主题(白底浅字不可读)。分段按钮全部内联样式,跟随面板配色。
|
|
@@ -463,7 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
463
468
|
}
|
|
464
469
|
|
|
465
470
|
// 模块级缓存:Panel 切走即卸载;报告与插件变更留在内存(刷新清空属预期)。
|
|
466
|
-
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 };
|
|
467
472
|
// v0.8.4:当前生效的入口重挂函数,由 apply() 填、由设置页调用。
|
|
468
473
|
// 存在它就不需要「改完要 Ctrl+R」——那前提在桌面版里根本不成立。
|
|
469
474
|
// ---------- 右下角临时弹窗(v0.9.1) ----------
|
|
@@ -524,6 +529,18 @@ window.__ModuleLoader__.load({
|
|
|
524
529
|
toastedIds.add(f.id);
|
|
525
530
|
pushToast(toastOf(f));
|
|
526
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
|
+
}
|
|
527
544
|
}
|
|
528
545
|
|
|
529
546
|
// 弹窗住在 shell.overlay 里,不在面板的树里 —— 点击「去看看」需要一条通道
|
|
@@ -1002,7 +1019,7 @@ window.__ModuleLoader__.load({
|
|
|
1002
1019
|
const gs = Array.isArray(res.groups) ? res.groups : [];
|
|
1003
1020
|
setGroups(gs);
|
|
1004
1021
|
setDraft(flatOf(gs));
|
|
1005
|
-
setSaved(res.savedAt ? "上次保存:" +
|
|
1022
|
+
setSaved(res.savedAt ? "上次保存:" + localStamp(res.savedAt) + "(本地时间)" : "还没改过(全部是推荐默认值)");
|
|
1006
1023
|
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1007
1024
|
finally { setLoading(false); }
|
|
1008
1025
|
}, []);
|
|
@@ -1106,7 +1123,7 @@ window.__ModuleLoader__.load({
|
|
|
1106
1123
|
const timer = setTimeout(() => { setLeft((v) => v - 1); }, 1000);
|
|
1107
1124
|
return () => clearTimeout(timer);
|
|
1108
1125
|
}, [left, paused]);
|
|
1109
|
-
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;
|
|
1110
1127
|
return h('div', {
|
|
1111
1128
|
// 鼠标移上去暂停计时:正在读的时候消失,比不弹更烦人。
|
|
1112
1129
|
onMouseEnter: () => setPaused(true),
|
|
@@ -1119,7 +1136,7 @@ window.__ModuleLoader__.load({
|
|
|
1119
1136
|
},
|
|
1120
1137
|
},
|
|
1121
1138
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 } },
|
|
1122
|
-
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' ? '提醒' : '留意'),
|
|
1123
1140
|
h('span', { style: { fontSize: 13, fontWeight: 600, flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t.title),
|
|
1124
1141
|
h('button', {
|
|
1125
1142
|
onClick: props.onDismiss, title: '关闭',
|
|
@@ -1128,15 +1145,15 @@ window.__ModuleLoader__.load({
|
|
|
1128
1145
|
),
|
|
1129
1146
|
t.body ? h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 7, whiteSpace: 'pre-wrap' } }, t.body) : null,
|
|
1130
1147
|
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
1131
|
-
h('button', {
|
|
1148
|
+
t.actionKey ? h('button', {
|
|
1132
1149
|
onClick: () => { props.onOpen(); props.onDismiss(); },
|
|
1133
1150
|
style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + tone, background: 'transparent', color: tone },
|
|
1134
|
-
}, '去看看'),
|
|
1135
|
-
h('button', {
|
|
1151
|
+
}, '去看看') : null,
|
|
1152
|
+
t.findingId ? h('button', {
|
|
1136
1153
|
onClick: () => { props.onAck(); props.onDismiss(); },
|
|
1137
1154
|
title: '静音这条,直到有效期结束',
|
|
1138
1155
|
style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.sub },
|
|
1139
|
-
}, '已知晓'),
|
|
1156
|
+
}, '已知晓') : null,
|
|
1140
1157
|
h('span', { style: { flex: '1 1 auto' } }),
|
|
1141
1158
|
h('span', { style: { fontSize: 10, color: C.faint, flex: '0 0 auto' } }, paused ? '暂停' : left + 's'),
|
|
1142
1159
|
),
|
|
@@ -1175,6 +1192,8 @@ window.__ModuleLoader__.load({
|
|
|
1175
1192
|
try {
|
|
1176
1193
|
const data = await call('report');
|
|
1177
1194
|
if (!alive) return;
|
|
1195
|
+
if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
|
|
1196
|
+
if (data && data.wiring) cache.wiring = data.wiring;
|
|
1178
1197
|
if (data && data.report) { cache.report = data.report; evaluateToasts(data.report); }
|
|
1179
1198
|
} catch { /* 采集失败不弹任何东西——宁可不提醒,也不误报 */ }
|
|
1180
1199
|
})();
|
|
@@ -1218,6 +1237,8 @@ window.__ModuleLoader__.load({
|
|
|
1218
1237
|
setLoading(true); setError('');
|
|
1219
1238
|
try {
|
|
1220
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;
|
|
1221
1242
|
try { publishReport(data && data.report); } catch { /* noop */ }
|
|
1222
1243
|
cache.report = data.report || null;
|
|
1223
1244
|
cache.error = '';
|
|
@@ -1233,12 +1254,13 @@ window.__ModuleLoader__.load({
|
|
|
1233
1254
|
try { setBundles(await call('bundles')); } catch { setBundles(null); }
|
|
1234
1255
|
}, []);
|
|
1235
1256
|
|
|
1236
|
-
//
|
|
1257
|
+
// 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过设置的分钟数→后台刷新。
|
|
1237
1258
|
// 先例:disk-cleaner 面板同样挂载即扫,同一 GUI 正常运行——二分定案证明卡死元凶不是自动扫描。
|
|
1238
1259
|
useEffect(() => {
|
|
1239
1260
|
if (!cache.report) { void scan(); return; }
|
|
1240
1261
|
const age = Date.now() - Date.parse(String(cache.report.generatedAt || ''));
|
|
1241
|
-
|
|
1262
|
+
const maxAge = (Number.isFinite(cache.autoScanMinutes) ? cache.autoScanMinutes : 10) * 60 * 1000;
|
|
1263
|
+
if (Number.isFinite(age) && age > maxAge) void scan();
|
|
1242
1264
|
}, [scan]);
|
|
1243
1265
|
|
|
1244
1266
|
const crashFinding = report && Array.isArray(report.findings)
|
|
@@ -1270,6 +1292,16 @@ window.__ModuleLoader__.load({
|
|
|
1270
1292
|
await scan();
|
|
1271
1293
|
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1272
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;
|
|
1273
1305
|
|
|
1274
1306
|
// 把面板内的能力交给 overlay 里的弹窗宿主。
|
|
1275
1307
|
React.useEffect(() => {
|
|
@@ -1316,8 +1348,10 @@ window.__ModuleLoader__.load({
|
|
|
1316
1348
|
finding: f,
|
|
1317
1349
|
onAck: !f.muted ? () => ackIt(f) : null,
|
|
1318
1350
|
onUnack: f.muted ? () => unackIt(f) : null,
|
|
1351
|
+
onFalsePositive: !f.falsePositive ? () => falsePositiveIt(f, false) : null,
|
|
1352
|
+
onUnfalsePositive: f.falsePositive ? () => falsePositiveIt(f, true) : null,
|
|
1319
1353
|
onGo: ACTION_FOR[f.id] && !f.muted && f.severity !== 'info' ? () => jumpToAction(ACTION_FOR[f.id]) : null,
|
|
1320
|
-
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,
|
|
1321
1355
|
});
|
|
1322
1356
|
const groupBlock = (label, color, list) => {
|
|
1323
1357
|
if (!list.length) return null;
|
|
@@ -1423,6 +1457,10 @@ window.__ModuleLoader__.load({
|
|
|
1423
1457
|
h('div', {}, '动作执行全部写入 audit.jsonl,可在「审计趋势」回看。'),
|
|
1424
1458
|
h('div', { style: { marginTop: 4 } }, '依赖清单与降级行为、设计取舍见插件目录 README(dsh-steward/README.md)。')) : null,
|
|
1425
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,
|
|
1426
1464
|
) : null,
|
|
1427
1465
|
|
|
1428
1466
|
tab === 'actions' ? h('div', { style: { marginTop: 12 } },
|
package/lib/index.js
CHANGED
|
@@ -212,6 +212,7 @@ function renderReportText(report) {
|
|
|
212
212
|
lines.push(report.summary.headline);
|
|
213
213
|
lines.push('');
|
|
214
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}`);
|
|
215
216
|
lines.push('');
|
|
216
217
|
for (const f of report.findings) {
|
|
217
218
|
lines.push(`## [${f.severity.toUpperCase()}] ${f.title}`);
|
|
@@ -270,6 +271,7 @@ export function apply(ctx) {
|
|
|
270
271
|
configBaseline: path.join(dataDir(), 'config-baseline.json'),
|
|
271
272
|
settings: path.join(dataDir(), 'settings.json'),
|
|
272
273
|
modelProbes: path.join(dataDir(), 'model-probes.json'),
|
|
274
|
+
feedback: path.join(dataDir(), 'feedback.json'),
|
|
273
275
|
};
|
|
274
276
|
// v0.8.0 设置:内存缓存 + 落盘。读不到/坏了 → 全默认(旧行为),绝不拦住体检。
|
|
275
277
|
let settingsCache = null;
|
|
@@ -375,6 +377,7 @@ export function apply(ctx) {
|
|
|
375
377
|
trendFile: files.trend,
|
|
376
378
|
knowledgeFile: files.knowledge,
|
|
377
379
|
ackedFile: files.acked,
|
|
380
|
+
feedbackFile: files.feedback,
|
|
378
381
|
});
|
|
379
382
|
|
|
380
383
|
// ---------- v0.9.1 模型健康 ----------
|
|
@@ -508,6 +511,8 @@ export function apply(ctx) {
|
|
|
508
511
|
if (action === 'report') {
|
|
509
512
|
let acked = [];
|
|
510
513
|
try { acked = (await io.readJson(files.acked)) || []; } catch { acked = []; }
|
|
514
|
+
let feedback = [];
|
|
515
|
+
try { feedback = (await io.readJson(files.feedback)) || []; } catch { feedback = []; }
|
|
511
516
|
const knowledge = await loadKnowledge();
|
|
512
517
|
// ④ v0.7.6 config drift: compare against the baseline recorded on the
|
|
513
518
|
// previous scan, then renew it. The baseline is ours (data dir), never the
|
|
@@ -528,13 +533,13 @@ export function apply(ctx) {
|
|
|
528
533
|
if (saved && saved.report && typeof saved.report === 'object') previous = saved.report;
|
|
529
534
|
} catch { previous = null; }
|
|
530
535
|
const modelHealth = await modelHealthForReport();
|
|
531
|
-
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 });
|
|
532
537
|
// Persist best-effort; a failed persist never fails the report itself.
|
|
533
538
|
io.writeJson(files.lastReport, { report: lastReport }).catch(() => {});
|
|
534
539
|
if (Array.isArray(lastReport.config) && lastReport.config.length > 0) {
|
|
535
540
|
io.writeJson(files.configBaseline, { at: lastReport.generatedAt, entries: lastReport.config }).catch(() => {});
|
|
536
541
|
}
|
|
537
|
-
return { report: lastReport, text: renderReportText(lastReport) };
|
|
542
|
+
return { report: lastReport, text: renderReportText(lastReport), autoScanMinutes: settingValue(currentSettings(), 'ui.autoScanMinutes'), wiring: { ...wiring } };
|
|
538
543
|
}
|
|
539
544
|
if (action === 'plan') return await hub.plan(payload.kind, payload);
|
|
540
545
|
if (action === 'execute') {
|
|
@@ -656,7 +661,7 @@ export function apply(ctx) {
|
|
|
656
661
|
} catch { auditLines = []; }
|
|
657
662
|
let trend = [];
|
|
658
663
|
try { const t = await io.readJson(files.trend); if (Array.isArray(t)) trend = t.slice(-20); } catch { trend = []; }
|
|
659
|
-
return { ok: true, audit: auditLines, trend };
|
|
664
|
+
return { ok: true, audit: auditLines, trend, trendVerdict: lastReport && lastReport.trendVerdict ? lastReport.trendVerdict : null };
|
|
660
665
|
}
|
|
661
666
|
return { ok: false, error: `unknown action: ${String(action)}` };
|
|
662
667
|
}
|
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.
|
|
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
|
|
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;
|
|
@@ -1427,6 +1428,52 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
|
|
|
1427
1428
|
&& /wiring\.via\[keys\.join\(','\)\] = 'one-shot'/.test(idxSrc));
|
|
1428
1429
|
}
|
|
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
|
+
|
|
1430
1477
|
console.log('');
|
|
1431
1478
|
console.log(`RESULT: ${pass} passed, ${fail} failed`);
|
|
1432
1479
|
process.exit(fail === 0 ? 0 : 1);
|