@sidady/dsh-steward 0.9.15 → 0.9.16

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
@@ -64,40 +64,67 @@
64
64
  > 五行的共同点:**它们都是「界面上看着有、其实没接上」**——设置一个值不生效、一个按钮点不到东西、
65
65
  > 一个时间差 8 小时、一条结论没人说。这类缺陷不会让体检报错,只会让人对面板失去信任。
66
66
  > 所以 A17–A21 一律判「接没接上 / 说没说清」,不判「代码写得对不对」。
67
+ >
68
+ > **2026-11-12 八次修订(v0.9.16):基线升到 v0.9.16 / 337 断言,新增 A22 一行。**
69
+ > **2026-11-13 补记(并入未发布的 0.9.16,不升号)**:修掉 Finding 卡「去处置」按钮白底白字(用户 m13209 截图报的纯白矩形)——`Btn` 里 primary 反相前景色被调用方 `tone` 顶掉,而 `C.text` 与 `C.solid` 同源于 `--dsw-alias-label-primary`。改法:删掉误传的 `tone`、`Btn` 在 primary 时强制 `C.on`,并补 4 条防回归静态判据(4/4 反向验证会红)。基线 337 → **341 断言**。这是显示 bug,不碰授权面、不碰数据层,随 0.9.16 一起发。
70
+ > **2026-11-14 补记(并入未发布的 0.9.16,不升号)**:两条用户现场反馈,全只读、不碰授权面——
71
+ > ①(m13463)插件新版本:`analyzeUpgrades()` 从市场缓存派生三档风险 + 利弊/建议中文人话 +
72
+ > `fingerprint` 去重专用提醒;**边界**:无 changelog 不编造具体修复内容、DSH 宿主自身新版本无
73
+ > 可读信号源,只分析市场缓存里的插件新版本。②(m13398)`collectFreeLaneHealth()` 只读
74
+ > `our-free-model/availability.json`,抓「标着可用却探测失败」与全 stale,出 info 级
75
+ > `free-lane-health`;**边界**:单一第三方插件 best-effort,没装属正常,不加 coverage 键
76
+ > (`sourcesTotal` 仍 7)/不 push error,返回体 `freeLane` 让跳过可观测,中期应等宿主把插件级
77
+ > 上游失败汇进 `diagnostics.jsonl`。基线 341 → **370 断言**(本轮 +29,新增 A23/A24 两行与一组断言;7 条关键判据反向验证过会红)。
78
+ > **口径变更,必须如实记**:11-12 曾按覆盖矩阵收口、约定「11-23 回看前不再加功能代码」;
79
+ > 但用户随后重新定义了核心定位(m12723「管家不只是听命令的工具,而是在一定授权内可以安全自主完成职责任务的智能帮手」),
80
+ > 并授权「现在做」(m12744)——那把尺子从「覆盖度」换成了「自主度」,原封板结论因此失效,这次不是违背约定而是**换了一把尺子重排权重**:
81
+ > 不会自己醒来当班的管家不是「差一点贴心」,是不成其为管家。为避免把「扩自主权」与「11-23 干净观察期」混在一起,
82
+ > 这一版**只做 L0 观察档**(周期体检 + 当班台账 + 预备建议,全只读、绝不碰授权面),L1/L2 受托执行仍不实现。
83
+ > - **A22** 专盯这次新增的自主度带来的新误报面:管家自己醒来后有没有制造噪音 / 有没有偷偷动手 / 定时器有没有泄漏。
84
+ >
85
+ > 设计文档另存 Mnemon `42042339-cc9d-4ecb-8d71-dc3ade7dffdd`(与定稿 f7e5a482 并列保留,不覆盖)。
86
+ > **2026-11-14 第二补记(并入未发布的 0.9.16,不升号)**:修右侧栏「智能管家」tab 点开后只剩标题、正文落到宿主兜底「这类内容还没有可用的查看方式。」(用户 m13878 截图)。根因:ui.entry 为 right 时 apply() 先挂 both、异步读到设置再挂 right,第二次 disposeAllNow() 把刚注册的 sidebar.right.pane.tab 正文槽位拆掉;且正文注册嵌在 sidebarRightTabs 的 inject 回调里。改法:右侧整套按 rightMounted 幂等挂卸、正文槽位顶层独立注册、ToastHost 独立 ctx.effect 常驻(旧版被 disposeAllNow 误清,提醒一直没真正生效)。新增 A25 一行与 5 静态 + 4 运行期 harness 断言(运行期把真正 apply() body 抠出来跑一遍 both→right 重挂,数正文槽位存活次数)。基线 370 → **379 断言**。显示 / 生命周期 bug,不碰授权面、不碰数据层。
67
87
 
68
- ## 基线快照(2026-11-11 19:40 采集(v0.9.15 重采),回看时用来对照)
88
+ ## 基线快照(2026-11-12 22:33 采集(v0.9.16 重采),回看时用来对照)
69
89
 
70
90
  | 项 | 基线值 |
71
91
  |---|---|
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 条反向验证过会红) |
77
- | 体检结论 | `level=warning`,`counts={critical:0, warning:2, info:10, muted:0}`,`errors=[]`,共 12 条 findings |
78
- | 两条 warning | `plugin-change`(本会话记录到 1 次插件变更,属事实非异常)+ `data-at-risk`(易失数据不在默认备份分区,长期项) |
79
- | `trendVerdict` | **新增字段**:`{state:"stable", worseningStreak:0, text:"与上一次体检相比没有明显变化。"}`;服务端算好人话、客户端只渲染 |
80
- | `autoScanMinutes` / `wiring` | report 返回体新增:`autoScanMinutes: 10`、`wiring.route:"registered"`;面板生成时间行下显示「接口已挂上 · 路由与工具随服务就绪后注册」 |
92
+ | bundle 版本 | `@sidady/dsh-steward` v0.9.16,installed + enabled;ping 回报 `version:"0.9.16"` 且 `wiring.route:"registered"`(三条 via 全 inject) |
93
+ | npm 上的版本 | **`0.9.15`**(`dist-tags.latest`)——**本机 0.9.16 尚未 publish**:m12744 只授权「现在做」L0 当班环,沿用「发布需用户逐次说要发」的口径,不主动推远端。回看时若仍不等,先确认是「故意未发」还是漏发 |
94
+ | GitHub | `stallenlu/dsh-steward` public,远端 `main` 最新提交 `3eca47b`(docs 同步到 0.9.15);本地 v0.9.16 系列已多次 commit(最新 HEAD 用 `git log -1` 查,回看当天以实测为准)、**均未 push / publish**(沿用「发布需用户逐次说要发」授权口径) |
95
+ | 目录条目 | awesome-dsh-plugin PR **#14042 已 MERGED**(2026-11-11T17:47:32Z,mergeCommit `07de6ca0…`,`Submission gate` + `check` 全 pass);main 分支已确认有 `data/plugins/stallenlu__dsh-steward.yml`(category dev,中英 description 齐全)。注意:`curl plugins.json` 曾被截断成假阴性,核对以 `gh api` 查 main 分支为准 |
96
+ | 本地验证 | 9 个 JS `node --check` 全过,`smoke.mjs` **379 passed / 0 failed**。演进:v0.9.15 基线 313 → L0 当班环 +24(含 1 条运行期求值判据、定时器生命周期判据)→ 337 → 白色按钮回归 +4 → 341 → 本轮(升级利弊分析 + 免费通道运行期体检)**+29 → 370** → 右侧栏 tab 正文修复 **+9 → 379**。关键判据反向验证过会红:L0 定时器 5 条、白按钮 4 条、升级利弊/免费通道 7 条、右侧栏 tab 正文 9 条 |
97
+ | 体检结论 | `level=warning`,`counts={critical:0, warning:2, info:11, muted:0}`,`errors=[]`,共 **13** 条 findings(比 v0.9.15 多一条 `config-drift` info) |
98
+ | `trendVerdict` | `{state:"stable", worseningStreak:0, text:"与上一次体检相比没有明显变化。"}` |
99
+ | **`duty`(v0.9.16 新增)** | report / ping / audit 三处返回体都带:`mode:"L0"`、`observeOnly:true`、`intervalMinutes:10`、`running:false`、`log`(当班台账)、`suggestions`(预备建议) |
100
+ | `duty.jsonl` | **1 行**(boot 那班):`trigger:"boot"`、`state:"observe-only"`、`executed:0`、seen「看了 13 条发现…其中 13 条仍在报」、6 条预备建议。L0 恒不调 plan/execute |
101
+ | `autoScanMinutes` / `wiring` | `autoScanMinutes: 10`、`wiring.route:"registered"`;面板生成时间行下显示「接口已挂上」+「当班 · … L0 观察档」 |
102
+ | 定时器生命周期 | L0 `setInterval` 挂在 `ctx.effect(..., 'dsh-steward: L0 duty')` 上,dispose 里 `clearInterval(dutyTimer)`——防 HMR 热重载后旧 fiber 的定时器不停 |
81
103
  | 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 个无法比较** |
104
+ | audit.jsonl | **42 行**(基线 40 → 42,新增 2 行是 11-11 20:26 用户手动 `clean` + `knowledge`;**本次 L0 当班后 0 行新增**,实测 `14:30Z` 之后无 plan/execute) |
105
+ | trend.json | **5 个点** |
106
+ | acked.json / feedback.json | `[]` / **尚不存在**(没有用户点过「这条判错了」就不会建文件) |
107
+ | 工作区 | 7 个(回看只比「7 个」与增长判定,不咬死体积) |
108
+ | 插件更新 | 比对 11 个已装包(回看以当天为准) |
87
109
  | 数据源覆盖 | `coverage` **7/7** = `data`,`unavailable=[]`,`errors=[]` |
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) |
110
+ | 配置指纹 | 回看时重取;`patch` 体量会变,只比 `- id:` 块数有没有减少,不要比字节 |
111
+ | 设置 | `savedAt 2026-11-11T14:00:45.505Z`;`ui.autoScanMinutes=10` 现在**一处管两处**(面板缓存刷新 + 后台当班周期),`model.probeOnReport:"cached"` |
112
+ | 客户端包 | 38 个声明 `dsh.client`,checked 38 / unreadable 0 / withoutId 0 |
113
+ | 默认模型 | `model-health` 可用(`probeOnReport:cached`);注:一次 report 曾 30 s 超时,是撞上模型探活,重试 0.22 s / 200,不是路由故障 |
114
+ | 兼容 / 加密备份 | 声明门槛的包适配宿主 `0.2.2-rc.1`;加密备份 2 份,最新 `dsh-config-2026-11-09-1749ba.zip` |
93
115
 
94
116
  > **版本号与基线数字的关系**:本表的数字是「当天真实跑出来的」,不是「必须永远等于这个」;
95
117
  > 回看时若断言数已经涨了(比如又加了几条),那是覆盖变多,记一笔新数字即可,不算退化。
96
118
  > 真正要盯的是:crash 日志有没有新增、`errors` 是否仍为空、`coverage` 是否仍 7/7、
97
- > `ping.wiring.route` 是否仍是 `registered`、`trendVerdict` / `autoScanMinutes` / `wiring` 三个返回体字段是否还在。
119
+ > `ping.wiring.route` 是否仍是 `registered`、`trendVerdict` / `autoScanMinutes` / `wiring` / `duty` 四个返回体字段是否还在(v0.9.16 起 `duty` 是第四个)。
98
120
  > **回看前先做一件事**:把 `package.json` 的 version、npm 上的 latest、ping 的 version 三个对一下。
99
- > 三者不等说明「磁盘改了 / 发了 / 宿主没重载」里有一环断了——此时面板看到的是旧代码,任何 A 项的结论都不作数。
100
- > **本次基线三者不等是已知状态**:本地 0.9.15 / npm 0.9.14 / ping 0.9.15,因为 0.9.15 还没发。发布后应回到三者一致。
121
+ > 三者不等**通常**说明「磁盘改了 / 发了 / 宿主没重载」里有一环断了——此时面板看到的是旧代码,任何 A 项的结论都不作数;
122
+ > 但也有例外(见下:本次是「故意未发」),先分清性质再下结论。
123
+ > **本次重采时三方不等,是已知且故意的状态**:本地 0.9.16 / ping 0.9.16 / npm `dist-tags.latest` **0.9.15** / GitHub `3eca47b`。
124
+ > 原因不是「磁盘改了 / 宿主没重载」里哪一环断了,而是 **m12744 只授权写代码、没授权发版**(沿用逐次授权)。
125
+ > 回看时若 11-23 当天已经是 0.9.16 且三方一致,记一笔新数字即可;若仍不等,先问「是故意未发还是漏发」,别直接判 ❌。
126
+ > v0.9.15 发布那次从 registry 反装 `npm pack @sidady/dsh-steward@0.9.15` 解包核对:17 文件、`lib/` 8 个 JS、8 个相对 import 全命中、
127
+ > 在 tarball 里跑 `node smoke.mjs` 仍 **313 passed / 0 failed**(那一版的基线)。
101
128
 
102
129
  ## 检查表
103
130
 
@@ -136,6 +163,10 @@
136
163
  | A19 | 误报反馈闭环是否成立 | `feedback.json` + 面板「这条判错了 / 撤销判错」 + `audit.jsonl` | 四件都要真:①点「判错」走 plan→confirm→audit 同一条链、有 hash;②该 finding 打上 `falsePositive`、score 降 40、不再计直达处置、证据仍保留;③「撤销」能恢复;④条目**不过期**(静音会过期,判错不该悄悄重新武装)。任何一件没接上 → ❌ |
137
164
  | A20 | 趋势结论与好转通知是不是服务端算的人话 | report `trendVerdict` + 面板「审计趋势」结论行 + ToastHost | `trendVerdict` 必须有 `state`(worsening / recovered / stable)、`worseningStreak`、中文 `text`,由服务端 `summarizeTrend()` 算好、客户端只渲染;好转或连续 ≥2 次变差要主动弹一条(用 `generatedAt` 去重,不许反复弹)。若趋势页仍只是流水行、或只报坏不报好 → ❌ |
138
165
  | A21 | 直达按钮不造假 + 自检结果用户可见 | `ACTION_FOR` + 动作卡清单 + 面板生成时间行 | ①`ACTION_FOR` 里每一条映射都要有对应的动作卡(v0.9.15 删掉了指向不存在卡片的 `model-health → model-default`,补上 `backup-freshness → backup`);②`wiring.route` 要显示在面板上,没挂上时给红字与恢复办法,不能只藏在 `ping` 里。若点了「去看看」跳到一个不存在的动作、或路由挂不上却面板无提示 → ❌ |
166
+ | A22 | **L0 当班环有没有守住「只观察、不动作」**(v0.9.16 新增自主度带来的新误报面) | `duty.jsonl` + `duty` 返回体 + `audit.jsonl` 增量 + 定时器生命周期 | 五件都满足才 ✅:①每班 `state:"observe-only"`、`executed:0`,`audit.jsonl` 在当班后**零新增** plan/execute;②`duty.jsonl` 行数随时间单调增长、每班有中文 `seen`/`action`/`suggestions`(预备建议 ≠ 已执行);③周期跟随 `ui.autoScanMinutes`,改设置后下一班按新周期重排;④`ping`/`report`/`audit` 三处都能查到 `duty`;⑤定时器挂在 `ctx.effect` 上且 dispose 清掉——**热重载/停用插件后旧 fiber 不许留下一个永不停止的当班环**。任一不满足 → ❌(尤其①,管家自己醒来后偷偷动手是比误报严重得多的越权) |
167
+ | A23 | 插件新版本的利弊/风险/建议是不是服务端算的人话、且不编造 | report `updates.advice` + `plugin-updates` finding + ToastHost 专用 version toast | 四件都满足才 ✅:①`advice` 有 `outdatedCount`/`rows`/`fingerprint`/`text`/`recommendation`/`toastTitle`/`toastBody`,三档风险(宿主门槛不符 high / 预览版或跨 ≥5 版本 medium / 常规小步 low)各自能触发;②`text` 明写「优点 / 风险 / 不编造具体修复内容」,不谎称读了 changelog;③`recommendation` 明写「管家只读分析、不代升级」;④toast 用 `fingerprint` 去重、不改 `toastWorthy`(info 级 finding 仍不全局弹)。若把「有新版」直接当成「该升」、或编造修复内容、或每次体检反复弹同一条 → ❌ |
168
+ | A24 | 免费通道运行期故障是不是被「只读、不越界」地接住 | `$DSH_HOME/our-free-model/availability.json` + report `freeLane` + `free-lane-health` finding | 四件都满足才 ✅:①只读**一家**第三方插件私有台账,抓「标着 available 却记着上游失败 detail」与全 stale;②缺台账/解析失败时**静默跳过**——不加 coverage 键(`sourcesTotal` 仍 7)、不 push 采集 error、不加 finding,但返回体 `freeLane` 让跳过可观测;③finding 是 info 级、不进 `ACTIONABLE_IDS`、不动别的插件文件;④明写边界(中期应等宿主把插件级上游失败汇进 `diagnostics.jsonl`)。若把「没装该插件」报成体检退化、或据此自动改配置/停用插件 → ❌ |
169
+ | A25 | 右侧栏「智能管家」tab 的正文有没有在设置切换后仍挂得住 | 右侧栏 tab 正文 + settings `ui.entry` + ToastHost + `smoke.mjs` 运行期 harness | 五件都满足才 ✅:①`ui.entry=right` 时初次挂 both、异步读到设置再挂 right,**正文槽位不许被二次重挂 dispose**——点开 tab 显示面板本体而不是宿主兜底「这类内容还没有可用的查看方式。」;②正文槽位 `sidebar.right.pane.tab`(key=PANEL_ID)在 apply() 顶层独立注册,不嵌在 `sidebarRightTabs` 的 inject 回调里;③右侧整套用 `rightMounted` 幂等门,wantRight 不变就不拆不挂;④ToastHost 用独立 `ctx.effect` 常驻、不进任何拆装列表(新版提醒会真正弹出);⑤`ui.entry` 三值(both/left/right)语义不变,right 时左侧入口与 main 全拆。运行期 harness 断言「正文恰好注册 1 次且两次重挂后仍存活、ToastHost 仍存活、right 时 leftLive=0」。若切到 right 后面板变空、或静态判据全绿但运行期正文已丢 → ❌ |
139
170
 
140
171
  ### B. 可回溯(每次动作都能查账)
141
172
 
@@ -158,7 +189,7 @@
158
189
 
159
190
  | # | 检查项 | 数据源 | 打勾条件 |
160
191
  |---|---|---|---|
161
- | D1 | 依赖缺失只降级不崩溃 | `node smoke.mjs` 的 fail-soft 断言 | 回看当天跑一次 smoke,仍全绿(基线 313 passed / 0 failed)→ ✅(若期间又加断言,以当天全绿为准并记下新数字) |
192
+ | D1 | 依赖缺失只降级不崩溃 | `node smoke.mjs` 的 fail-soft 断言 | 回看当天跑一次 smoke,仍全绿(基线 379 passed / 0 failed)→ ✅(若期间又加断言,以当天全绿为准并记下新数字) |
162
193
  | 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),要看新文案是否出现 |
163
194
  | D3 | 可选数据源缺失不拖垮报告 | 报告 `status` 与 `coverage` | 缺一个可选源时 `status` 仍是 `ok`、只在 `coverage` 里记 `unavailable` → ✅ |
164
195
  | D4 | 慢请求不会无限等待 | 面板发一次 `model-scan` | 服务端 3 路并发(12 探针最坏 ≈180 s),前端 `report`/`model-scan` 预算 240 s、普通动作 30 s。若面板转圈超过 5 分钟仍无任何提示 → ❌ |
@@ -206,6 +237,15 @@ curl -s -o "$TMP/rep.json" -X POST http://127.0.0.1:<当前端口>/api/dsh-stewa
206
237
  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
238
  # 误报反馈:点一次「这条判错了」后应出现 feedback.json,且该 finding 带 falsePositive、score 降 40
208
239
  cat ~/.dsh/dsh-steward/feedback.json 2>/dev/null || echo '(还没有判错记录)'
240
+
241
+ # 8) v0.9.16 L0 当班台账(A22)——确认「只观察不动作」
242
+ wc -l ~/.dsh/dsh-steward/duty.jsonl
243
+ node -e "const fs=require('fs');const L=fs.readFileSync(process.argv[1],'utf8').split('\\n').filter(Boolean).map(JSON.parse);console.log('班次',L.length,'最新',JSON.stringify(L[L.length-1]))" ~/.dsh/dsh-steward/duty.jsonl
244
+ # 关键:把 duty.jsonl 里最新一班的 at 与 audit.jsonl 最后几行比对,
245
+ # 当班之后若冒出新的 execute 行 = L0 越权,直接判 ❌
246
+ tail -5 ~/.dsh/dsh-steward/audit.jsonl
247
+ # 定时器生命周期(静态):smoke 里那条 ctx.effect/clearInterval 判据必须全绿且反向验证过会红
248
+ grep -n "dsh-steward: L0 duty" dsh-steward/lib/index.js
209
249
  ```
210
250
 
211
251
  > 端口每轮启动都会变,用运行时上下文里的 GUI URL,不要沿用旧端口。
@@ -259,6 +299,13 @@ cat ~/.dsh/dsh-steward/feedback.json 2>/dev/null || echo '(还没有判错记录
259
299
  (不过期、降权、但证据保留)。两者混成一个入口,两周验收就学不到东西(A19)。
260
300
  - **好转也要说**:只报坏不报好,用户永远不知道管家在变好还是在变差;但好转通知必须去重,
261
301
  否则每次体检都弹一次,本身就成了新的噪音(A20)。
302
+ - **自主度必须可撤销、可回溯、可观测**:管家自己醒来当班,带来的是一整类**新的**误报面——
303
+ 不是「报得不准」而是「它会不会偷偷动手 / 会不会每热重载一次就多一个永不停止的定时器」。
304
+ 所以 L0 的边界要写成可核对的事实:`executed:0`、`audit.jsonl` 零增量、`duty.jsonl` 有中文台账、
305
+ 定时器归 `ctx.effect` 管(A22)。**「没写 execute」不算证据,「当班后审计零新增」才算**。
306
+ - **换尺子要记口径变更**:11-12 的封板约定(不再加功能代码)被用户重新定义核心定位后推翻,
307
+ 这不是违背约定,是评判轴从「覆盖度」换成「自主度」。回看时若见到「说好不加却加了」,
308
+ 先查有没有对应的用户授权与本文档的修订记录,别直接当成退化。
262
309
 
263
310
  ## 回看结论栏(11-23 填)
264
311
 
package/README.md CHANGED
@@ -1,6 +1,80 @@
1
1
  # dsh-steward — 智能管家(体检 + 授权执行)插件
2
2
 
3
- **状态:v0.9.15 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项)**
3
+ **状态:v0.9.16 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项 + L0 当班)**
4
+
5
+ ## v0.9.16(L0 当班环:管家会自己醒来,但只观察、只把建议备好)
6
+
7
+ 「核心定位再思考」之后落地的第一档,也是**只做 L0** 的一版。此前管家只在开机 45 秒后
8
+ 体检一次、或你打开面板时才动——它不像「管家」,更像一件等你用的工具。v0.9.16 补的是
9
+ 「当班」这件事本身:不开面板,它也按设置周期自己醒来体检一遍。
10
+
11
+ - **周期当班**:`ui.autoScanMinutes`(原来只管面板缓存刷新,现在「一个数字管两处」)
12
+ 同时驱动后台 `setInterval`。改设置后下一班自动按新周期重排,不用重启。带重入保护
13
+ (上一班没结束不开新班)与 1 秒去抖,不会因事件抖动连开好几班。
14
+ - **当班台账 `duty.jsonl`**:每班一条,写清「看了什么 / 判断 / 做了没做 / 为什么没做」。
15
+ 人话由服务端 `dutyBriefing()` 算好,客户端只渲染(沿用 USER.md 的口径)。
16
+ - **预备建议**:把仍在报、且没被静音/判错的发现的建议预先整理成清单,等你来动作中心
17
+ 确认;**预备 ≠ 执行**。
18
+ - **硬边界(L0 观察档)**:`runDuty()` 全程只读,一次都不调用 `plan`/`execute`;
19
+ 报告 / ping / audit 返回体带 `duty`,面板「体检总览」显示最近一班状态,「审计趋势」
20
+ 显示当班台账。加密备份口令、启动/boot/`cordis.patch.yml`、停用启用插件、改默认模型
21
+ 等一律不进任何自动层(见「明确不做」)。
22
+ - **档位路线**:L0 观察(本版,默认开)→ L1 预备(主动把计划推到面前,一次确认,默认关)
23
+ → L2 受托(白名单内可逆动作按策略自动执行并回报,默认关、须逐项开)。L1/L2 **未实现**,
24
+ 设计见 Mnemon 文档 `42042339-cc9d-4ecb-8d71-dc3ade7dffdd`。扩大自主权要等 11-23 两周
25
+ 无误报回看之后,本版不碰授权面。
26
+
27
+ 静态判据(smoke)钉住:周期定时器存在且带重入/去抖、当班全程不触碰 plan/execute、
28
+ `dutyBriefing` 是纯函数且排除静音/判错项、三处返回体都带 duty、面板显示当班状态与台账。
29
+
30
+ ## v0.9.16 补记(并入未发布版,不升号):升级利弊分析 + 免费通道运行期体检
31
+
32
+ 两条都来自用户现场反馈,**全只读、不碰授权面**:
33
+
34
+ - **插件新版本,管家会主动提醒并给利弊与建议**(m13463)。`collectUpdates` 现在多收一个
35
+ `hostVersion`(宿主版本取自崩溃日志的 `meta.app`),`analyzeUpgrades()` 纯函数从市场缓存的
36
+ `versions[]` / `engines.dsh` 派生三档风险:宿主门槛不符(high,升了可能直接起不来)、预览版 /
37
+ 跨 ≥5 个版本(medium)、常规小步(low);把「优点 / 风险 / 分级 / 处理建议」写成中文人话塞进
38
+ `plugin-updates` 的 detail 与 recommendation,并在面板用 `updates.advice.fingerprint` 弹一条
39
+ **去重**的专用提醒(不改 `toastWorthy`,info 级 finding 仍不全局放开)。
40
+ **边界**:市场缓存里没有 changelog,优点只写「跟随上游 / 依赖对齐 / 减少碎片」这类可证的,
41
+ 不编造具体修复内容;DSH 宿主自身没有可读的新版本信号源,本版只分析市场缓存里的**插件**
42
+ 新版本,不假装能测宿主自更新。
43
+ - **our-free-model 的运行期故障,管家能看见了**(m13398)。`collectFreeLaneHealth()` 只读
44
+ `$DSH_HOME/our-free-model/availability.json`,专抓「标着 available、最近一次探测却失败」的
45
+ 自相矛盾与「探测结果全部 stale」,出一条 info 级 `free-lane-health`。
46
+ **边界**:这是**单一第三方插件**的 best-effort 加分项,没装它属正常,因此**不加 coverage 键、
47
+ 不 push 采集错误**(不给所有没装它的用户造永久误报),返回体用 `freeLane` 让「跳过」在 data
48
+ 层可观测;不读别家私有台账、不碰别的插件代码。中期更稳的做法是等宿主把插件级上游失败汇进
49
+ `bot/logs/diagnostics.jsonl`,管家接一层而非接 N 家。
50
+
51
+ 静态判据(smoke)钉住:三档风险各自触发、`fingerprint` 确定性且顺序无关、利弊文案不编造
52
+ changelog、`collectUpdates` 收 `hostVersion` 且返回 `advice`、免费通道缺台账时静默跳过且
53
+ `sourcesTotal` 仍 7、客户端 version toast 有去重指纹。
54
+
55
+ ## v0.9.16 补记(并入未发布版,不升号):右侧栏 tab 正文「这类内容还没有可用的查看方式」修复
56
+
57
+ 用户 (m13878) 截图报:右侧栏「智能管家」标签点开后,正文区显示宿主的蓝色兜底文字
58
+ 「这类内容还没有可用的查看方式。」——芯片标题还在、面板本体没了。
59
+
60
+ - **根因不是插件没挂载,是二次重挂把正文拆了**。旧 `apply()` 把右侧 tab 的「类型注册 +
61
+ 正文槽位 + 浮层提醒」全塞进 `mountRight()`,而 `mountFor(entry)` 每次都先 `disposeAllNow()`
62
+ 全拆一遍。`ui.entry='right'` 时:先挂 `both`(正文注册好)→ 异步读到设置再挂 `right`
63
+ → 这第二次重挂把刚注册好的 `sidebar.right.pane.tab` 正文 dispose 掉;更糟的是正文还嵌在
64
+ `ctx.inject(['sidebarRightTabs'])` 的回调里,服务重载时机一错也丢。宿主 `TabSlot` 用
65
+ `entryKey = definition?.id ?? tab.kind` 找 keyed 槽位,找不到就渲染 `tab.unavailable`
66
+ 兜底;芯片标题是打开时捕获的,所以「标题在、正文空」。
67
+ - **修法三件**:①右侧整套按 `wantRight` **幂等**挂/卸(`rightMounted` 门,wantRight 不变就不拆
68
+ 不挂),正文槽位 `ctx.slots.inject('sidebar.right.pane.tab', …)` 提到 `apply()` 顶层直接注册,
69
+ 不再依赖 `sidebarRightTabs` 服务的回调——对照 dsh-config-manager / dsh-better-sidebar /
70
+ dsh-univer-office 都是这么做的;②浮层 ToastHost 用独立 `ctx.effect` 常驻,不进任何拆装列表
71
+ (旧版被 `disposeAllNow()` 误清,提醒一直没真正生效);③left/main 单独幂等挂/卸,`ui.entry`
72
+ 三值语义不变。
73
+ - **验证**:smoke 新增 5 条静态判据 + 4 条运行期 harness 断言(把真正的 `apply()` body 抠出来,
74
+ 用假 Cordis ctx 跑一遍「both → settings=right」重挂,数正文槽位到底被注册/拆了几次)。
75
+ 静态判据看不出的二次重挂竞态,运行期 harness 能看出来。
76
+
77
+ 这是显示 / 生命周期 bug,不碰授权面、不碰数据层、不新增检测项,随 0.9.16 一起发。
4
78
 
5
79
  ## v0.9.15(把「该有的职能」补齐:时间、设置、反馈、趋势、直达)
6
80
 
@@ -357,7 +431,7 @@ v0.7 给这个输入框加了「模型理解」:可选的第二通话方式,
357
431
  - `lib/brain.js`:v0.7 模型理解层。把一句话交给宿主 `llm` 服务,把模型输出归一成与 `intent.js` **完全同形状**的意图;JSON 抽取容错(容忍围栏与前后废话),各字段走白名单校验,任何不合规都降级为 clarify。**只翻译,不执行。**
358
432
  - `lib/index.js`:host(路由 + `dsh_steward_report` / `dsh_steward_act` 两个工具 + 事件订阅 + boot 后自动体检 + systemPrompt 注入)。
359
433
  - `lib/client.js`:client(`__ModuleLoader__.load` + 三 Tab 面板 + 动作卡 + 侧栏/右侧栏入口,全内联样式)。
360
- - `smoke.mjs`:本地验证(不启动宿主),292 项断言(含打包自检、时间戳、配色与「延迟注册」四类静态规约判据)。
434
+ - `smoke.mjs`:本地验证(不启动宿主),379 项断言(另含右侧栏 tab 正文修复的静态与运行期 harness 判据)(含打包自检、时间戳、配色、「延迟注册」、L0 当班环、白色按钮回归、升级利弊分析与免费通道运行期体检等静态与运行期判据)。
361
435
 
362
436
  ## 本地验证(不装、不重启)
363
437
  ```
package/lib/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-steward · client entry — v0.9.15 (model-mode conversational entry + L1 briefing;
2
+ * dsh-steward · client entry — v0.9.16 (L0 observe-only duty loop + model-mode 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
@@ -210,7 +210,12 @@ window.__ModuleLoader__.load({
210
210
  // a green primary button reads as "this is healthy", which is data.
211
211
  border: '1px solid ' + (props.primary ? 'transparent' : C.line),
212
212
  background: props.primary && !props.disabled ? C.solid : C.fill,
213
- color: props.tone || (props.primary && !props.disabled ? C.on : C.text), marginRight: 8,
213
+ // A primary button inverts the surface: its foreground MUST be the
214
+ // inverted fg. C.text and C.solid both map to --dsw-alias-label-primary,
215
+ // so letting a caller's `tone` win here paints white-on-white (a blank
216
+ // bar instead of a button). Colour is reserved for data — emphasis is
217
+ // inversion, and inversion cannot be overridden by an accent.
218
+ color: props.primary && !props.disabled ? C.on : (props.tone || C.text), marginRight: 8,
214
219
  },
215
220
  }, props.children);
216
221
  }
@@ -282,7 +287,7 @@ window.__ModuleLoader__.load({
282
287
  style: { fontSize: 11.5, marginTop: 6, padding: '5px 8px', borderRadius: 6, background: C.fill, border: '1px dashed ' + C.info, color: C.sub, whiteSpace: 'pre-wrap' },
283
288
  }, '🧠 上次也出现过 · ' + localStamp(f.recall.at) + ':「' + (f.recall.headline || '') + '」(当时严重 ' + ((f.recall.counts && f.recall.counts.critical) || 0) + ' / 留意 ' + ((f.recall.counts && f.recall.counts.warning) || 0) + '),详情见「审计趋势」的记录)') : null,
284
289
  h('div', { style: { marginTop: 8, display: 'flex', gap: 0, flexWrap: 'wrap' } },
285
- props.onGo ? h(Btn, { onClick: props.onGo, primary: true, tone: C.text }, props.goLabel || '去处置 →') : null,
290
+ props.onGo ? h(Btn, { onClick: props.onGo, primary: true }, props.goLabel || '去处置 →') : null,
286
291
  props.onAck ? h(TinyBtn, { onClick: props.onAck }, '已知晓(静音此项)') : null,
287
292
  props.onUnack ? h(TinyBtn, { onClick: props.onUnack, tone: C.info }, '恢复显示此项') : null,
288
293
  props.onFalsePositive ? h(TinyBtn, { onClick: props.onFalsePositive, tone: C.warning }, '这条判错了') : null,
@@ -426,6 +431,13 @@ window.__ModuleLoader__.load({
426
431
  '这里是管家的操作台账:它经你授权动过系统的每一次记录都写在这里,包括被拒绝的尝试。', h('br'),
427
432
  '用途:核对「上次到底清了什么、什么时候、成没成」,以及看体检结论随时间是变好还是变坏。'),
428
433
  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,
434
+ data && data.duty ? h('div', { style: { fontSize: 12, marginBottom: 10, padding: '8px 10px', borderRadius: 6, borderLeft: '2px solid ' + C.ok, background: C.fill, color: C.text, lineHeight: 1.7 } },
435
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '当班台账 · ' + (data.duty.latest ? localStamp(data.duty.latest.at) : '还没当过班') + '(L0 观察档 · 每 ' + data.duty.intervalMinutes + ' 分钟一班)'),
436
+ data.duty.latest ? h('div', {}, data.duty.latest.seen) : h('div', { style: { color: C.faint } }, '管家会在后台按周期自己醒来体检;这一版只观察、只把建议备好,不代替你按确认。'),
437
+ data.duty.latest ? h('div', {}, data.duty.latest.action) : null,
438
+ data.duty.suggestions && data.duty.suggestions.length ? h('div', { style: { marginTop: 4, color: C.sub } }, '预备好的处置建议(等你确认):' + data.duty.suggestions.map((s) => s.text).join(';')) : null,
439
+ Array.isArray(data.duty.log) && data.duty.log.length > 1 ? h('div', { style: { marginTop: 4, color: C.faint, fontSize: 11 } }, '最近 ' + data.duty.log.length + ' 班:' + data.duty.log.slice(-5).map((d) => localStamp(d.at) + ' ' + d.state).join(' · ')) : null,
440
+ ) : null,
429
441
  h('div', { style: { marginBottom: 10, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
430
442
  h(Btn, { onClick: load, disabled: loading }, loading ? '读取中…' : '刷新台账'),
431
443
  // 不用原生 <select>:Windows 下拉弹层由系统绘制,无视宿主主题(白底浅字不可读)。分段按钮全部内联样式,跟随面板配色。
@@ -468,7 +480,7 @@ window.__ModuleLoader__.load({
468
480
  }
469
481
 
470
482
  // 模块级缓存:Panel 切走即卸载;报告与插件变更留在内存(刷新清空属预期)。
471
- const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null, autoScanMinutes: 10, wiring: null };
483
+ const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null, autoScanMinutes: 10, wiring: null, duty: null };
472
484
  // v0.8.4:当前生效的入口重挂函数,由 apply() 填、由设置页调用。
473
485
  // 存在它就不需要「改完要 Ctrl+R」——那前提在桌面版里根本不成立。
474
486
  // ---------- 右下角临时弹窗(v0.9.1) ----------
@@ -541,6 +553,19 @@ window.__ModuleLoader__.load({
541
553
  body: tv.text || '' });
542
554
  }
543
555
  }
556
+ // v0.9.16(未发布):插件新版本——服务端已算好利弊/风险/建议,客户端只弹一次。
557
+ // 不改 toastWorthy(info 级 finding 仍不全局放开),用独立 key 按版本指纹去重。
558
+ const ua = report && report.updates && report.updates.advice;
559
+ if (ua && ua.fingerprint && ua.outdatedCount > 0 && toastConfig.mode !== 'off') {
560
+ const vkey = 'version-' + ua.fingerprint;
561
+ if (!toastedIds.has(vkey)) {
562
+ toastedIds.add(vkey);
563
+ pushToast({ kind: 'version', findingId: null, actionKey: null,
564
+ severity: 'info',
565
+ title: ua.toastTitle || '有插件出了新版本',
566
+ body: ua.toastBody || '' });
567
+ }
568
+ }
544
569
  }
545
570
 
546
571
  // 弹窗住在 shell.overlay 里,不在面板的树里 —— 点击「去看看」需要一条通道
@@ -1194,6 +1219,7 @@ window.__ModuleLoader__.load({
1194
1219
  if (!alive) return;
1195
1220
  if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
1196
1221
  if (data && data.wiring) cache.wiring = data.wiring;
1222
+ if (data && data.duty) cache.duty = data.duty;
1197
1223
  if (data && data.report) { cache.report = data.report; evaluateToasts(data.report); }
1198
1224
  } catch { /* 采集失败不弹任何东西——宁可不提醒,也不误报 */ }
1199
1225
  })();
@@ -1239,6 +1265,7 @@ window.__ModuleLoader__.load({
1239
1265
  const data = await call('report');
1240
1266
  if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
1241
1267
  if (data && data.wiring) cache.wiring = data.wiring;
1268
+ if (data && data.duty) cache.duty = data.duty;
1242
1269
  try { publishReport(data && data.report); } catch { /* noop */ }
1243
1270
  cache.report = data.report || null;
1244
1271
  cache.error = '';
@@ -1461,6 +1488,8 @@ window.__ModuleLoader__.load({
1461
1488
  cache.wiring.route === 'registered'
1462
1489
  ? '接口已挂上 · 路由与工具随服务就绪后注册'
1463
1490
  : '接口未挂上(' + String(cache.wiring.route) + ')——停用再启用本插件,或重启 DSH Web') : null,
1491
+ cache.duty ? h('div', { style: { fontSize: 10.5, marginTop: 4, color: C.faint } },
1492
+ '当班 · ' + (cache.duty.lastAt ? localStamp(cache.duty.lastAt) : '待首班') + ' · L0 观察档(每 ' + cache.duty.intervalMinutes + ' 分钟自检一次,只观察不动作)') : null,
1464
1493
  ) : null,
1465
1494
 
1466
1495
  tab === 'actions' ? h('div', { style: { marginTop: 12 } },
@@ -1534,81 +1563,99 @@ window.__ModuleLoader__.load({
1534
1563
  return {
1535
1564
  inject: ['slots'],
1536
1565
  apply(ctx) {
1537
- // v0.8.4:入口切换不再需要刷新页面。
1538
- // 旧版只在 apply() 里读一次 ui.entry,改完必须重载整个 Web 界面才生效;
1539
- // 而桌面版里 Ctrl+R 未必有绑定(用户实测「好像没有效果」)——把「改完要刷新」
1540
- // 当成使用前提,本身就是设计缺陷:一个设置项不该要求用户去猜快捷键。
1541
- // 现在两个入口先都挂上,读到设置再裁掉不要的那个;设置页保存成功后通过
1542
- // ENTRY_APPLIER 就地重挂,全程不刷新。
1543
- let currentDisposers = [];
1544
- const disposeAllNow = () => {
1545
- for (const d of currentDisposers) { try { d(); } catch { /* noop */ } }
1546
- currentDisposers = [];
1566
+ // 三处注册彼此独立、按入口状态幂等地挂/卸,只在插件整体卸载时由框架级联销毁。
1567
+ // 曾经的 bug:把右侧栏 tab 的「类型 + 正文 + 浮层提醒」全塞进 mountRight(),又让
1568
+ // mountFor() 每次都 disposeAllNow() 全拆一遍。ui.entry='right' 时初次挂 both、异步
1569
+ // 读到设置后再挂 right,这第二次重挂会把刚注册好的正文槽位 dispose 掉;更糟的是
1570
+ // 正文注册还嵌在 ctx.inject(['sidebarRightTabs']) 的回调里,服务重载时机一错就丢。
1571
+ // 宿主把它当成「会话日志里恢复的、插件已不再挂载的旧 tab」,显示「这类内容还没有
1572
+ // 可用的查看方式。」——芯片标题是打开时捕获的,所以标题还在、正文空了。
1573
+ // 现在:右侧整套按 wantRight 幂等挂/卸(wantRight 不变就不拆不挂),正文槽位顶层
1574
+ // 直接挂、不再依赖 sidebarRightTabs 服务的回调,浮层提醒独立常驻不进任何拆装。
1575
+ // 对照:config-manager / better-sidebar / univer-office 都把正文槽位顶层一次性注册。
1576
+
1577
+ // ① 右侧栏 tab:类型 + 正文。用 rightMounted 做幂等门,避免每次 mountFor 反复拆装。
1578
+ let rightMounted = false;
1579
+ let rightDisposers = [];
1580
+ const unmountRight = () => {
1581
+ for (const d of rightDisposers) { try { d(); } catch { /* noop */ } }
1582
+ rightDisposers = [];
1583
+ rightMounted = false;
1547
1584
  };
1548
-
1549
1585
  const mountRight = () => {
1550
- const disposers = [];
1586
+ if (rightMounted) return; // 幂等:wantRight 没变就别动,否则正文会被二次重挂 dispose 掉
1587
+ rightMounted = true;
1588
+ // 正文(keyed 槽位):key 恒为 PANEL_ID(= 类型实现 id)。顶层直接挂,
1589
+ // 不夹带在 sidebarRightTabs 的 inject 回调里——服务重载不会连累正文。
1590
+ try {
1591
+ rightDisposers.push(ctx.slots.inject('sidebar.right.pane.tab', () =>
1592
+ ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PANEL_ID }, (p) =>
1593
+ h(Panel, { host: (p && p.host) || 'sidebar' })),
1594
+ ));
1595
+ } catch { /* sidebar body unavailable — contained */ }
1596
+ // 类型(sidebarRightTabs):服务可能晚于 apply 就绪,必须用 ctx.inject 延迟注册;
1597
+ // 用注入进来的 scoped ctx 的 effect 管生命周期,服务重跑时先 dispose 旧类型再注册。
1551
1598
  try {
1552
1599
  const off = ctx.inject(['sidebarRightTabs'], (c) => {
1553
1600
  try {
1554
1601
  const svc = c && c.sidebarRightTabs;
1555
1602
  if (!svc || typeof svc.register !== 'function') return;
1556
- const r = svc.register({
1557
- id: PANEL_ID, kind: PANEL_ID, title: () => '智能管家',
1558
- guide: [{ id: PANEL_ID, order: 25, title: () => '智能管家', description: () => '稳定 · 可靠 · 整洁 · 知识的授权守护', icon: PanelIcon }],
1559
- });
1560
- if (typeof r === 'function') disposers.push(r);
1561
- disposers.push(ctx.slots.inject('sidebar.right.pane.tab', () =>
1562
- ctx.slots.register({ name: 'sidebar.right.pane.tab', key: PANEL_ID }, (p) =>
1563
- h(Panel, { host: (p && p.host) || 'sidebar' })),
1564
- ));
1603
+ c.effect(() => {
1604
+ const dispose = svc.register({
1605
+ id: PANEL_ID, kind: PANEL_ID, title: () => '智能管家',
1606
+ guide: [{ id: PANEL_ID, order: 25, title: () => '智能管家', description: () => '稳定 · 可靠 · 整洁 · 知识的授权守护', icon: PanelIcon }],
1607
+ });
1608
+ return () => { try { if (typeof dispose === 'function') dispose(); } catch { /* noop */ } };
1609
+ }, 'dsh-steward: right tab type');
1565
1610
  } catch { /* sidebar unavailable — contained */ }
1566
1611
  });
1567
- if (typeof off === 'function') disposers.push(off);
1612
+ if (typeof off === 'function') rightDisposers.push(off);
1568
1613
  } catch { /* inject unsupported — contained */ }
1569
- return () => { for (const d of disposers) { try { d(); } catch { /* noop */ } } };
1570
1614
  };
1571
1615
 
1572
- // 浮层宿主与入口无关:三个入口模式下提醒都必须能到,所以在 mountFor 之外
1573
- // 单独挂一次,且只挂一次(重复注册会让同一个提醒弹两遍)。
1616
+ // ② 浮层提醒:与入口无关,独立挂一次;不再放进任何会被 disposeAllNow 清掉的列表。
1574
1617
  try {
1575
- currentDisposers.push(ctx.slots.inject('shell.overlay', () =>
1576
- ctx.slots.register(
1577
- { name: 'shell.overlay', id: PANEL_ID + '-toast', order: 900, label: () => '智能管家提醒' },
1578
- () => h(ToastHost, {
1579
- onOpen: (actionKey) => {
1580
- try { if (PANEL_NAVIGATOR) PANEL_NAVIGATOR(actionKey); } catch { /* noop */ }
1581
- },
1582
- onAck: (findingId) => {
1583
- try { if (PANEL_ACK && findingId) PANEL_ACK(findingId); } catch { /* noop */ }
1584
- },
1585
- }),
1586
- )));
1618
+ ctx.effect(() => {
1619
+ const disposeToast = ctx.slots.inject('shell.overlay', () =>
1620
+ ctx.slots.register(
1621
+ { name: 'shell.overlay', id: PANEL_ID + '-toast', order: 900, label: () => '智能管家提醒' },
1622
+ () => h(ToastHost, {
1623
+ onOpen: (actionKey) => { try { if (PANEL_NAVIGATOR) PANEL_NAVIGATOR(actionKey); } catch { /* noop */ } },
1624
+ onAck: (findingId) => { try { if (PANEL_ACK && findingId) PANEL_ACK(findingId); } catch { /* noop */ } },
1625
+ }),
1626
+ ));
1627
+ return () => { try { if (typeof disposeToast === 'function') disposeToast(); } catch { /* noop */ } };
1628
+ }, 'dsh-steward: toast overlay');
1587
1629
  } catch { /* overlay 不可用:提醒降级为只出现在面板里,不影响体检 */ }
1588
1630
 
1589
- // entry: 'both' | 'left' | 'right'
1590
- // left = 左侧栏导航项 + main 面板本体(点导航项打开的就是 main)
1591
- // right = 右侧栏标签页;只留右侧栏时不挂 left/main,避免两处重复
1631
+ // ③ 左侧入口 / main:随 ui.entry 切换。这部分拆装只管 left/main,不碰右侧 tab。
1632
+ let leftMounted = false;
1633
+ let currentDisposers = [];
1634
+ const disposeAllNow = () => {
1635
+ for (const d of currentDisposers) { try { d(); } catch { /* noop */ } }
1636
+ currentDisposers = [];
1637
+ };
1638
+ const mountLeft = () => {
1639
+ if (leftMounted) return;
1640
+ leftMounted = true;
1641
+ try {
1642
+ currentDisposers.push(ctx.slots.inject('sidebar.panellist', () =>
1643
+ ctx.slots.register(
1644
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 55, label: () => '智能管家' },
1645
+ (p) => h(PanelIcon, { size: p && p.size }),
1646
+ )));
1647
+ } catch { /* contained */ }
1648
+ try {
1649
+ currentDisposers.push(ctx.slots.inject('main', () =>
1650
+ ctx.slots.register({ name: 'main', key: PANEL_ID }, () => h(Panel))));
1651
+ } catch { /* contained */ }
1652
+ };
1653
+ const unmountLeft = () => { disposeAllNow(); leftMounted = false; };
1592
1654
  const mountFor = (entry) => {
1593
- disposeAllNow();
1594
1655
  const wantLeft = entry !== 'right';
1595
1656
  const wantRight = entry !== 'left';
1596
- if (wantLeft) {
1597
- try {
1598
- currentDisposers.push(ctx.slots.inject('sidebar.panellist', () =>
1599
- ctx.slots.register(
1600
- { name: 'sidebar.panellist', id: PANEL_ID, order: 55, label: () => '智能管家' },
1601
- (p) => h(PanelIcon, { size: p && p.size }),
1602
- )));
1603
- } catch { /* contained */ }
1604
- try {
1605
- currentDisposers.push(ctx.slots.inject('main', () =>
1606
- ctx.slots.register({ name: 'main', key: PANEL_ID }, () => h(Panel))));
1607
- } catch { /* contained */ }
1608
- }
1609
- if (wantRight) {
1610
- try { currentDisposers.push(mountRight()); } catch { /* contained */ }
1611
- }
1657
+ if (wantLeft) mountLeft(); else unmountLeft();
1658
+ if (wantRight) mountRight(); else unmountRight();
1612
1659
  };
1613
1660
 
1614
1661
  try {
@@ -1619,7 +1666,8 @@ window.__ModuleLoader__.load({
1619
1666
  };
1620
1667
  ctx.effect(() => () => {
1621
1668
  ENTRY_APPLIER = null;
1622
- disposeAllNow();
1669
+ unmountLeft();
1670
+ unmountRight();
1623
1671
  }, 'dsh-steward: entries');
1624
1672
  } catch { /* contained */ }
1625
1673
 
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * Plans are zero-write; executes are audited; passwords never persist.
15
15
  * Contract for routes/tools mirrors the running dsh-disk-cleaner@1.6.2 host entry.
16
16
  */
17
- import { collectReport } from './report.js';
17
+ import { collectReport, dutyBriefing } from './report.js';
18
18
  import { createActionHub } from './actions.js';
19
19
  import { parseIntent } from './intent.js';
20
20
  import { runModel, normalizeBrainOutput } from './brain.js';
@@ -272,6 +272,7 @@ export function apply(ctx) {
272
272
  settings: path.join(dataDir(), 'settings.json'),
273
273
  modelProbes: path.join(dataDir(), 'model-probes.json'),
274
274
  feedback: path.join(dataDir(), 'feedback.json'),
275
+ duty: path.join(dataDir(), 'duty.jsonl'),
275
276
  };
276
277
  // v0.8.0 设置:内存缓存 + 落盘。读不到/坏了 → 全默认(旧行为),绝不拦住体检。
277
278
  let settingsCache = null;
@@ -365,6 +366,63 @@ export function apply(ctx) {
365
366
  }
366
367
  } catch { /* nothing persisted — start clean */ }
367
368
  }
369
+ // ---------- v0.9.16 L0 当班环(observe-only,绝不碰授权面)----------
370
+ // 管家按 ui.autoScanMinutes 周期自己醒来体检,把「这一班看了什么 / 判断 /
371
+ // 做了没做 / 为什么没做」写进 duty.jsonl;预备建议由 dutyBriefing() 在服务端
372
+ // 算成人话,客户端只渲染。L0 永远不调用 plan/execute —— 观察档不代替你按确认。
373
+ const DUTY_LOG_CAP = 60;
374
+ let dutyRunning = false;
375
+ let dutyLastAt = 0;
376
+ let dutyTimer = null;
377
+
378
+ function dutyIntervalMs() {
379
+ const minutes = Number(settingValue(currentSettings(), 'ui.autoScanMinutes'));
380
+ const safe = Number.isFinite(minutes) && minutes >= 1 ? minutes : 10;
381
+ return Math.max(1, safe) * 60 * 1000;
382
+ }
383
+
384
+ async function readDutyLog() {
385
+ try {
386
+ const raw = await fsp.readFile(files.duty, 'utf8');
387
+ const lines = raw.split('\n').filter(Boolean).slice(-DUTY_LOG_CAP);
388
+ const out = [];
389
+ for (const line of lines) {
390
+ try { out.push(JSON.parse(line)); } catch { /* 跳过坏行,不让一条脏数据毁掉台账 */ }
391
+ }
392
+ return out;
393
+ } catch { return []; }
394
+ }
395
+
396
+ async function dutySnapshot() {
397
+ const log = await readDutyLog();
398
+ const latest = log.length ? log[log.length - 1] : null;
399
+ return {
400
+ mode: 'L0',
401
+ observeOnly: true,
402
+ intervalMinutes: Math.round(dutyIntervalMs() / 60000),
403
+ running: dutyRunning,
404
+ lastAt: latest ? latest.at : null,
405
+ latest,
406
+ log,
407
+ suggestions: latest && Array.isArray(latest.suggestions) ? latest.suggestions : [],
408
+ };
409
+ }
410
+
411
+ async function runDuty(trigger) {
412
+ if (dutyRunning) return null; // 重入保护:上一班没结束就不开新班
413
+ if (Date.now() - dutyLastAt < 1000) return null; // 去抖:1 秒内不重复当班
414
+ dutyRunning = true;
415
+ try {
416
+ const res = await run('report', {}); // 复用既有只读体检(采集 + 落盘)
417
+ const report = res && res.report ? res.report : (lastReport || {});
418
+ const briefing = dutyBriefing(report, { trigger: String(trigger || 'scheduled') });
419
+ await io.appendJsonl(files.duty, briefing);
420
+ dutyLastAt = Date.now();
421
+ return briefing;
422
+ } catch { return null; } // 当班失败绝不冒泡影响宿主
423
+ finally { dutyRunning = false; }
424
+ }
425
+
368
426
  const hub = createActionHub({
369
427
  routeFetch: makeRouteFetch(ctx),
370
428
  getPluginManager: () => getService(ctx, 'pluginManager'),
@@ -496,7 +554,7 @@ export function apply(ctx) {
496
554
  }
497
555
 
498
556
  async function run(action, payload) {
499
- if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring } };
557
+ if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring }, duty: await dutySnapshot() };
500
558
  // v0.8.0 settings: 只读设置页需要的一切(值 + 范围 + 为什么),以及一次写入。
501
559
  if (action === 'settings') {
502
560
  await loadSettings();
@@ -539,7 +597,7 @@ export function apply(ctx) {
539
597
  if (Array.isArray(lastReport.config) && lastReport.config.length > 0) {
540
598
  io.writeJson(files.configBaseline, { at: lastReport.generatedAt, entries: lastReport.config }).catch(() => {});
541
599
  }
542
- return { report: lastReport, text: renderReportText(lastReport), autoScanMinutes: settingValue(currentSettings(), 'ui.autoScanMinutes'), wiring: { ...wiring } };
600
+ return { report: lastReport, text: renderReportText(lastReport), autoScanMinutes: settingValue(currentSettings(), 'ui.autoScanMinutes'), wiring: { ...wiring }, duty: await dutySnapshot() };
543
601
  }
544
602
  if (action === 'plan') return await hub.plan(payload.kind, payload);
545
603
  if (action === 'execute') {
@@ -661,7 +719,7 @@ export function apply(ctx) {
661
719
  } catch { auditLines = []; }
662
720
  let trend = [];
663
721
  try { const t = await io.readJson(files.trend); if (Array.isArray(t)) trend = t.slice(-20); } catch { trend = []; }
664
- return { ok: true, audit: auditLines, trend, trendVerdict: lastReport && lastReport.trendVerdict ? lastReport.trendVerdict : null };
722
+ return { ok: true, audit: auditLines, trend, trendVerdict: lastReport && lastReport.trendVerdict ? lastReport.trendVerdict : null, duty: await dutySnapshot() };
665
723
  }
666
724
  return { ok: false, error: `unknown action: ${String(action)}` };
667
725
  }
@@ -681,11 +739,46 @@ export function apply(ctx) {
681
739
  // without waiting for a human to ask. Read-only, fail-soft, never throws.
682
740
  try {
683
741
  const bootTimer = setTimeout(() => {
684
- run('report', {}).catch(() => {});
742
+ runDuty('boot').catch(() => {});
685
743
  }, 45_000);
686
744
  if (typeof bootTimer.unref === 'function') bootTimer.unref();
687
745
  } catch { /* timer unavailable */ }
688
746
 
747
+ // 1b-2) L0 当班:按设置周期自己醒来体检一遍(只读,绝不自动执行动作)。
748
+ // 周期跟随 ui.autoScanMinutes,改设置后下一班自动按新周期重排,不用重启。
749
+ let dutySpanMs = dutyIntervalMs();
750
+ const dutyTick = () => {
751
+ const next = dutyIntervalMs();
752
+ if (next !== dutySpanMs) {
753
+ dutySpanMs = next;
754
+ if (dutyTimer) clearInterval(dutyTimer);
755
+ dutyTimer = setInterval(dutyTick, dutySpanMs);
756
+ if (dutyTimer && typeof dutyTimer.unref === 'function') dutyTimer.unref();
757
+ }
758
+ runDuty('scheduled').catch(() => {});
759
+ };
760
+ // 必须挂在 ctx.effect 上:fiber 被 HMR 换掉 / 插件停用时,dispose 会把定时器一起清掉。
761
+ // 裸 setInterval 在旧 fiber 里不会停 —— 每热重载一次就多一个永不停止的当班环
762
+ // (与 v0.9.14 那个「旧路由被 dispose、新的没接上」是同一类生命周期错误)。
763
+ try {
764
+ ctx.effect(() => {
765
+ try {
766
+ dutyTimer = setInterval(dutyTick, dutySpanMs);
767
+ if (typeof dutyTimer.unref === 'function') dutyTimer.unref();
768
+ } catch { /* timer unavailable — 周期当班静默降级,不影响其它功能 */ }
769
+ return () => {
770
+ if (dutyTimer) { clearInterval(dutyTimer); dutyTimer = null; }
771
+ };
772
+ }, 'dsh-steward: L0 duty');
773
+ } catch {
774
+ // ctx.effect 不可用(极老宿主):退化为裸定时器,宁可多跑一班也不能让 boot 挂在这里。
775
+ try {
776
+ dutyTimer = setInterval(dutyTick, dutySpanMs);
777
+ if (typeof dutyTimer.unref === 'function') dutyTimer.unref();
778
+ } catch { /* ignore */ }
779
+ }
780
+
781
+
689
782
  // 1c) v0.4 proactive exit, part 2: systemPrompt.section — PROVEN shape from
690
783
  // @changfenhuang/dsh-genui/lib/index.js:7772 ({name, order, text}, order via
691
784
  // getSectionOrder). Guarded: if the service or getSectionOrder is missing we
package/lib/report.js CHANGED
@@ -416,10 +416,99 @@ function versionDirection(a, b) {
416
416
  return verCmp(a, b);
417
417
  }
418
418
 
419
- export function collectUpdates(paths, io, errors) {
419
+ function shortHash(text) {
420
+ let h = 5381;
421
+ for (let i = 0; i < text.length; i += 1) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
422
+ return h.toString(16).padStart(8, '0');
423
+ }
424
+
425
+ function hostVersionFromCrashes(crashes) {
426
+ for (const c of crashes || []) {
427
+ const app = c && c.meta && c.meta.app;
428
+ if (typeof app !== 'string') continue;
429
+ const m = /\s(\d+\.\d+\.\d+[^\s]*)/.exec(app);
430
+ if (m) return m[1];
431
+ }
432
+ return null;
433
+ }
434
+
435
+ export function collectFreeLaneHealth(paths, io) {
436
+ const NL2 = String.fromCharCode(10);
437
+ const file = path.join(paths.dshHome, 'our-free-model', 'availability.json');
438
+ const raw = io.readText(file);
439
+ const skipped = { available: false, reason: '没有读到 our-free-model 的运行台账', checked: 0, staleCount: 0, newestAt: null, failing: [], contradictory: [], allStale: false };
440
+ if (!raw || raw.__error || !String(raw).trim()) return skipped;
441
+ let parsed;
442
+ try { parsed = JSON.parse(raw); } catch (e) { return { ...skipped, reason: '运行台账无法解析' }; }
443
+ const results = (parsed && parsed.results) || {};
444
+ const entries = Object.entries(results).filter(([, v]) => v && typeof v === 'object');
445
+ let staleCount = 0;
446
+ let newestAt = null;
447
+ const failing = [];
448
+ const contradictory = [];
449
+ for (const [name, v] of entries) {
450
+ if (v.stale === true) staleCount += 1;
451
+ const at = typeof v.at === 'string' || typeof v.at === 'number' ? String(v.at) : '';
452
+ if (at && (!newestAt || at > newestAt)) newestAt = at;
453
+ const detail = typeof v.detail === 'string' ? v.detail.trim() : '';
454
+ if (!detail) continue;
455
+ failing.push({ name, state: String(v.state || ''), detail: detail.slice(0, 160) });
456
+ if (String(v.state) === 'available') contradictory.push({ name, detail: detail.slice(0, 160) });
457
+ }
458
+ return {
459
+ available: true, reason: null, checked: entries.length, staleCount, newestAt,
460
+ failing, contradictory, allStale: entries.length > 0 && staleCount === entries.length,
461
+ };
462
+ }
463
+
464
+ export function analyzeUpgrades(outdated, packages, hostVersion) {
465
+ const NL = String.fromCharCode(10);
466
+ const rows = (outdated || []).map((u) => {
467
+ const meta = (packages && packages[u.name]) || {};
468
+ const versions = Array.isArray(meta.versions) ? meta.versions : [];
469
+ let skipped = 0;
470
+ for (const v of versions) {
471
+ if (versionDirection(v, u.installed) > 0 && versionDirection(v, u.latest) < 0) skipped += 1;
472
+ }
473
+ const enginesDsh = meta.engines && typeof meta.engines.dsh === 'string' ? meta.engines.dsh : null;
474
+ const hostFit = enginesDsh && hostVersion ? satisfiesRange(enginesDsh, hostVersion) : null;
475
+ let risk = 'low';
476
+ const reasons = [];
477
+ if (hostFit === false) { risk = 'high'; reasons.push('新版本声明的宿主版本门槛与当前宿主不符,升了可能直接起不来'); }
478
+ if (String(u.latest).indexOf('-') >= 0) { if (risk === 'low') risk = 'medium'; reasons.push('目标版本带预览/预发布后缀,稳定性未经充分验证'); }
479
+ if (skipped >= 5) { if (risk === 'low') risk = 'medium'; reasons.push('中间跨过 ' + skipped + ' 个已发布版本,变更面较大'); }
480
+ if (!reasons.length) reasons.push('未发现宿主门槛/预览版/跨版本信号,属常规小步更新');
481
+ return { name: u.name, installed: u.installed, latest: u.latest, skipped, enginesDsh, hostFit, risk, reasons };
482
+ });
483
+ const fingerprint = shortHash(rows.map((r) => r.name + '@' + r.installed + '->' + r.latest).sort().join('|'));
484
+ const high = rows.filter((r) => r.risk === 'high');
485
+ const medium = rows.filter((r) => r.risk === 'medium');
486
+ const low = rows.filter((r) => r.risk === 'low');
487
+ const riskLines = [
488
+ high.length ? '· 高风险(先别升):' + high.map((r) => r.name + '(' + r.reasons[0] + ')').join('、') : null,
489
+ medium.length ? '· 需留意:' + medium.map((r) => r.name + '(' + r.reasons[0] + ')').join('、') : null,
490
+ low.length ? '· 可照常安排:' + low.map((r) => r.name).join('、') : null,
491
+ ].filter(Boolean);
492
+ const text = [
493
+ '优点:跟随上游维护、与依赖/宿主版本对齐、减少长期停留在旧版带来的碎片;只读版本清单,没有 changelog,不编造具体修复内容。',
494
+ '风险:升级是「插件变更 → boot 风险」的上游;一次装错会让整个 Web UI 起不来。',
495
+ '分级:' + (riskLines.length ? riskLines.join(' ') : '无'),
496
+ ].join(NL);
497
+ const recommendation = [
498
+ '按风险从低到高逐个升、一次只升一个,升完看「启动诊断」;高风险项先确认宿主版本或等后续修复,不要在要赶活的时候动它们。',
499
+ '升级前确认真能备份到当前可用状态。管家只读分析、不代升级。',
500
+ ].join(NL);
501
+ return {
502
+ outdatedCount: rows.length, rows, fingerprint, text, recommendation,
503
+ toastTitle: rows.length + ' 个插件有新版本:附风险与处理建议',
504
+ toastBody: text + NL + recommendation,
505
+ };
506
+ }
507
+
508
+ export function collectUpdates(paths, io, errors, hostVersion) {
420
509
  const file = path.join(paths.dshHome, 'plugin-update-cache.json');
421
510
  const raw = io.readText(file);
422
- const empty = { available: false, reason: '没有找到市场更新缓存', outdated: [], checked: 0, cacheAgeHours: null };
511
+ const empty = { available: false, reason: '没有找到市场更新缓存', outdated: [], checked: 0, cacheAgeHours: null, advice: null };
423
512
  // Optional source, same rule as the workspace index: absence is information
424
513
  // (and gets surfaced by the confidence layer), not a degraded report.
425
514
  if (!raw || raw.__error || !String(raw).trim()) return empty;
@@ -479,7 +568,8 @@ export function collectUpdates(paths, io, errors) {
479
568
  }
480
569
  outdated.sort((a, b) => a.name.localeCompare(b.name));
481
570
  aheadOfCache.sort((a, b) => a.name.localeCompare(b.name));
482
- return { available: true, reason: null, outdated, aheadOfCache, checked, undetermined, cacheAgeHours };
571
+ const advice = analyzeUpgrades(outdated, packages, hostVersion);
572
+ return { available: true, reason: null, outdated, aheadOfCache, checked, undetermined, cacheAgeHours, advice };
483
573
  }
484
574
 
485
575
  /**
@@ -865,6 +955,64 @@ function collectClientIdentity(paths, io, errors) {
865
955
  * this run's { level, counts }. worseningStreak rides along in the report so
866
956
  * consecutive declines can be counted without a second data store.
867
957
  */
958
+ /**
959
+ * v0.9.16 · L0 当班台账的「判断 + 回报」纯函数。
960
+ *
961
+ * 这一版的管家会自己按时醒来体检(不带人、不带面板),但**只观察、不动手**:
962
+ * 任何写动作都不做。为了不把「没做事」写成一片空白,每班都留一条能被人
963
+ * 读懂的记录:看了什么、判断是什么、做了没做、为什么没做。
964
+ *
965
+ * 纯函数,不碰文件、不碰时钟(at 由调用方传),便于 smoke 直接喂夹具断言。
966
+ * 服务端算好人话、客户端只渲染(USER.md):这里产出的就是那句人话。
967
+ */
968
+ export function dutyBriefing(report, options = {}) {
969
+ const at = typeof options.at === 'string' ? options.at : new Date().toISOString();
970
+ const trigger = String(options.trigger || 'scheduled');
971
+ const findings = report && Array.isArray(report.findings) ? report.findings : [];
972
+ const counts = report && report.summary && report.summary.counts ? report.summary.counts : null;
973
+ const level = report && report.summary ? String(report.summary.level || 'unknown') : 'unknown';
974
+ const trend = report && report.trendVerdict ? report.trendVerdict : null;
975
+
976
+ // 看了什么:按级别数一遍,明确区分「静音/判错」与「还在报的」。
977
+ const live = findings.filter((f) => !f.muted && !f.falsePositive);
978
+ const seen = '看了 ' + findings.length + ' 条发现'
979
+ + (counts ? '(严重 ' + (counts.critical || 0) + ' · 留意 ' + (counts.warning || 0) + ' · 提示 ' + (counts.info || 0) + ')' : '')
980
+ + ',其中 ' + live.length + ' 条仍在报。';
981
+
982
+ // 判断:趋势结论服务端已经算成人话,直接引用;没有就给当前级别。
983
+ const verdict = trend && trend.text
984
+ ? trend.text
985
+ : (level === 'warning' ? '这一班有需要留意的项目,但没有严重问题。'
986
+ : level === 'critical' ? '这一班出现了严重问题,已留待你处置。'
987
+ : '这一班没有需要留意的问题。');
988
+
989
+ // 预备建议:把仍在报、且给了 recommendation 的 finding 提炼成清单(零副作用)。
990
+ // 只取非静音、非判错的,且最多 6 条——避免把整份报告复述一遍。
991
+ const suggestions = live
992
+ .filter((f) => typeof f.recommendation === 'string' && f.recommendation.trim())
993
+ .slice(0, 6)
994
+ .map((f) => ({ id: f.id, severity: f.severity, text: f.recommendation.trim() }));
995
+
996
+ // 做了没做 / 为什么没做:L0 的硬边界,写死且说清,别让「没做事」显得像漏了。
997
+ const action = '未执行任何动作:L0 是观察档,管家只体检、只把建议备好,不代替你按确认。'
998
+ + (suggestions.length
999
+ ? '已把 ' + suggestions.length + ' 条处置建议预备好,等你来动作中心确认。'
1000
+ : '这一班没有需要预备的处置建议。');
1001
+
1002
+ return {
1003
+ at,
1004
+ trigger,
1005
+ level,
1006
+ counts: counts || null,
1007
+ seen,
1008
+ verdict,
1009
+ action,
1010
+ suggestions,
1011
+ state: 'observe-only',
1012
+ executed: 0,
1013
+ };
1014
+ }
1015
+
868
1016
  export function summarizeTrend(previous, current) {
869
1017
  const cur = current || {};
870
1018
  const rank = (lv) => (lv === 'critical' ? 3 : lv === 'warning' ? 2 : 0);
@@ -904,6 +1052,7 @@ export function collectReport(options = {}) {
904
1052
 
905
1053
  const bootedAt = options.bootedAt || new Date(Date.now() - Math.floor(process.uptime()) * 1000);
906
1054
  const crashes = collectCrashLogs(paths, io, errors, limits);
1055
+ const resolvedHostVersion = options.hostVersion || hostVersionFromCrashes(crashes);
907
1056
  const patchBackups = collectPatchBackups(paths, io, errors);
908
1057
  const pluginChanges = Array.isArray(options.pluginChanges) ? options.pluginChanges.slice(0, 50) : [];
909
1058
 
@@ -1090,7 +1239,7 @@ export function collectReport(options = {}) {
1090
1239
  // this stays an information item with a pointer, not a button.
1091
1240
  let updateInfo = { available: false, outdated: [], checked: 0, cacheAgeHours: null };
1092
1241
  try {
1093
- updateInfo = collectUpdates(paths, io, errors);
1242
+ updateInfo = collectUpdates(paths, io, errors, resolvedHostVersion);
1094
1243
  if (!updateInfo.available) {
1095
1244
  findings.push({
1096
1245
  id: 'plugin-updates', severity: 'info', category: '版本适配',
@@ -1105,6 +1254,7 @@ export function collectReport(options = {}) {
1105
1254
  // Those are not updates — they mean the cache is older than the disk, which is
1106
1255
  // worth saying out loud exactly once instead of silently hiding them.
1107
1256
  const ahead = updateInfo.aheadOfCache || [];
1257
+ const advice = updateInfo.advice || null;
1108
1258
  const aheadNote = ahead.length > 0
1109
1259
  ? '\n另有 ' + ahead.length + ' 个包的已装版本比缓存里的 latest 还新('
1110
1260
  + ahead.slice(0, 3).map((u) => u.name + ' ' + u.installed + ' > ' + u.latest).join('、')
@@ -1122,10 +1272,10 @@ export function collectReport(options = {}) {
1122
1272
  detail: updateInfo.outdated.slice(0, 10).map((u) => '· ' + u.name + ':' + u.installed + ' → ' + u.latest).join('\n')
1123
1273
  + (updateInfo.outdated.length > 10 ? '\n(共 ' + updateInfo.outdated.length + ' 个)' : '')
1124
1274
  + aheadNote + unknownNote
1125
- + '\n\n升级是「插件变更 → boot 风险」的上游;升级前先确认真能备份到当前可用状态。',
1275
+ + (advice ? '\n\n' + advice.text : '\n\n升级是「插件变更 → boot 风险」的上游;升级前先确认真能备份到当前可用状态。'),
1126
1276
  evidence: '来源:$DSH_HOME/plugin-update-cache.json(' + (updateInfo.cacheAgeHours != null ? updateInfo.cacheAgeHours + ' 小时前刷新' : '刷新时间未知') + ');对比 ' + updateInfo.checked + ' 个已装包;版本方向按 semver 比,只把「更新」算作可用',
1127
- recommendation: '逐个升而不是批量升,升完看「启动诊断」;不要在要赶活的时候动它们。管家不代升级。',
1128
- data: { outdated: updateInfo.outdated.slice(0, 20), aheadOfCache: ahead.slice(0, 20), undetermined: updateInfo.undetermined || 0, checked: updateInfo.checked, cacheAgeHours: updateInfo.cacheAgeHours },
1277
+ recommendation: advice ? advice.recommendation : '逐个升而不是批量升,升完看「启动诊断」;不要在要赶活的时候动它们。管家不代升级。',
1278
+ data: { outdated: updateInfo.outdated.slice(0, 20), aheadOfCache: ahead.slice(0, 20), undetermined: updateInfo.undetermined || 0, checked: updateInfo.checked, cacheAgeHours: updateInfo.cacheAgeHours, advice },
1129
1279
  });
1130
1280
  } else if (updateInfo.checked > 0) {
1131
1281
  findings.push({
@@ -1145,6 +1295,48 @@ export function collectReport(options = {}) {
1145
1295
  errors.push({ step: 'plugin-updates', error: String((e && e.message) || e) });
1146
1296
  }
1147
1297
 
1298
+ // ①b v0.9.16(未发布):只读一家第三方插件的运行台账。没装这个插件是正常状态,
1299
+ // 不是体检退化——所以不加 coverage 键、不 push error。只在真读到、且出现
1300
+ // 「标着可用却失败」或「最近一次探测失败」时给一条 info,零副作用。
1301
+ let freeLane = { available: false, reason: 'not checked' };
1302
+ try {
1303
+ freeLane = collectFreeLaneHealth(paths, io);
1304
+ if (freeLane.available && (freeLane.contradictory.length > 0 || freeLane.failing.length > 0)) {
1305
+ const contra = freeLane.contradictory;
1306
+ const NLf = String.fromCharCode(10);
1307
+ const staleNote = freeLane.allStale
1308
+ ? NLf + '台账里 ' + freeLane.checked + ' 个模型的探测结果全部标着 stale,说明这份结果本身可能已过期。'
1309
+ : '';
1310
+ const contraNote = contra.length
1311
+ ? NLf + '其中 ' + contra.length + ' 条自相矛盾:' + contra.slice(0, 3).map((x) => x.name).join('、')
1312
+ + (contra.length > 3 ? ' 等' : '') + ' 标着 available,却同时记着最近一次上游失败。'
1313
+ : '';
1314
+ findings.push({
1315
+ id: 'free-lane-health',
1316
+ severity: 'info',
1317
+ category: '运行期健康',
1318
+ title: contra.length
1319
+ ? contra.length + ' 个免费模型标着「可用」、最近一次探测却失败'
1320
+ : freeLane.failing.length + ' 个免费模型最近一次探测失败',
1321
+ detail: '这是 our-free-model 插件自己的运行台账(availability.json),不是 boot 崩溃、也不是宿主 LLM 实时探活能看到的信号。'
1322
+ + NLf + '· 共检查 ' + freeLane.checked + ' 个模型,' + freeLane.failing.length + ' 个记着上游失败 detail。'
1323
+ + contraNote + staleNote
1324
+ + NLf + '典型 detail:' + (freeLane.failing[0] ? freeLane.failing[0].detail : '(无)'),
1325
+ evidence: '只读 $DSH_HOME/our-free-model/availability.json(最新探测 ' + (freeLane.newestAt || '未知') + ');'
1326
+ + 'staleCount ' + freeLane.staleCount + '/' + freeLane.checked + ';未改任何插件文件',
1327
+ recommendation: '先别把这些模型设成默认/首选;上游失败常见原因是缺本地 Bun 或免费通道被限流。'
1328
+ + '这是插件私有台账,管家只读、不碰它、不代修;中期应等宿主把插件级上游失败写进 diagnostics 再统一接一层。',
1329
+ data: {
1330
+ checked: freeLane.checked, staleCount: freeLane.staleCount, allStale: freeLane.allStale,
1331
+ newestAt: freeLane.newestAt, failing: freeLane.failing.slice(0, 10),
1332
+ contradictory: contra.slice(0, 10),
1333
+ },
1334
+ });
1335
+ }
1336
+ } catch (e) {
1337
+ // best-effort only — never degrade the report for an optional signal
1338
+ }
1339
+
1148
1340
  // ② v0.7.7 workspace / session inventory. The report previously mentioned
1149
1341
  // sessions only inside a prose string; this measures them.
1150
1342
  //
@@ -1716,6 +1908,7 @@ export function collectReport(options = {}) {
1716
1908
  config: configNow,
1717
1909
  workspace: workspaceInfo,
1718
1910
  updates: updateInfo,
1911
+ freeLane,
1719
1912
  coverage,
1720
1913
  status: errors.length > 0 ? 'degraded' : 'ok',
1721
1914
  findings,
@@ -1727,6 +1920,7 @@ export function collectReport(options = {}) {
1727
1920
  '不 import 任何其它 DSH 包内部模块,避免版本漂移。',
1728
1921
  '崩溃检测是「启动后读日志」,不是实时守护;本进程内事件随宿主 fatal 消失。',
1729
1922
  '采集限流:日志 ≤ ' + limits.maxLogs + ' 份、单份 ≤ ' + limits.maxBytesPerLog + ' B(可在面板「设置」里调)。',
1923
+ '运行期插件健康只接了一家:只读 $DSH_HOME/our-free-model/availability.json,没装该插件属正常、不算体检退化;DSH 宿主自身的新版本没有可读信号源,本版只分析市场缓存里的插件新版本。',
1730
1924
  ],
1731
1925
  errors,
1732
1926
  };
package/lib/settings.js CHANGED
@@ -91,9 +91,9 @@ const SPEC = [
91
91
 
92
92
  // ── 界面 ──────────────────────────────────────────────────────
93
93
  {
94
- key: 'ui.autoScanMinutes', group: '界面', label: '面板缓存的自动刷新间隔',
94
+ key: 'ui.autoScanMinutes', group: '界面', label: '自动体检 / 后台当班周期',
95
95
  unit: '分钟', min: 1, max: 240, def: 10,
96
- why: '切回面板时,缓存超过这个时间就在后台重新体检一次(不会打断你)。设为 1 分钟 = 几乎每次都刷;设大 = 更省,但看到的可能是几分钟前的样子。',
96
+ why: '一个数字管两处:切回面板时缓存超过这个时间就后台重体检一次;管家不开面板时也按这个周期自己醒来体检一遍(v0.9.16 L0 当班)。设为 1 分钟 = 几乎每次都刷;设大 = 更省,但看到的可能是几分钟前的样子。改完下一班就按新周期走,不用重启。',
97
97
  },
98
98
  {
99
99
  key: 'ui.entry', group: '界面', label: '面板入口', type: 'choice', def: 'both',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sidady/dsh-steward",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
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
@@ -6,7 +6,7 @@
6
6
  * probes the fail-soft path (unreadable directories) and the real machine
7
7
  * (best-effort) to confirm the parser works on actual crash logs.
8
8
  */
9
- import { collectReport, parseBootFailure } from './lib/report.js';
9
+ import { collectReport, parseBootFailure, dutyBriefing, analyzeUpgrades, collectFreeLaneHealth } from './lib/report.js';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs';
12
12
  import { fileURLToPath } from 'node:url';
@@ -1474,6 +1474,247 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
1474
1474
  check("P2-b the panel shows the wiring outcome", clientSrc.includes("接口已挂上"));
1475
1475
  }
1476
1476
 
1477
+ {
1478
+ // ---- v0.9.16: L0 当班环(只读、可观测、绝不自动执行) ----
1479
+ const dutyIndex = fs.readFileSync(path.join(here, 'lib', 'index.js'), 'utf8');
1480
+ const dutyClient = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1481
+ const dutyReport = fs.readFileSync(path.join(here, 'lib', 'report.js'), 'utf8');
1482
+
1483
+ const mkDutyReport = (findings, level, text) => ({
1484
+ summary: { level: level || 'warning', counts: { critical: 0, warning: 2, info: 3, muted: 0 } },
1485
+ trendVerdict: text ? { state: 'stable', text } : null,
1486
+ findings: findings || [],
1487
+ });
1488
+ const dOk = { id: 'a', severity: 'warning', recommendation: '去动作中心看看 A', muted: false, falsePositive: false };
1489
+ const dMuted = { id: 'b', severity: 'info', recommendation: '这条被静音', muted: true, falsePositive: false };
1490
+ const dFalse = { id: 'c', severity: 'critical', recommendation: '这条被判错', muted: false, falsePositive: true };
1491
+ const dB = dutyBriefing(mkDutyReport([dOk, dMuted, dFalse]), { at: '2026-11-12T22:00:00.000Z', trigger: 'boot' });
1492
+ check('dutyBriefing is pure: at + trigger pass through unchanged', dB.at === '2026-11-12T22:00:00.000Z' && dB.trigger === 'boot', JSON.stringify({ at: dB.at, trigger: dB.trigger }));
1493
+ check('dutyBriefing records state=observe-only and executed=0', dB.state === 'observe-only' && dB.executed === 0);
1494
+ check('dutyBriefing excludes muted and false-positive findings', dB.suggestions.length === 1 && dB.suggestions[0].id === 'a', JSON.stringify(dB.suggestions));
1495
+ check('dutyBriefing writes a human "seen" line', typeof dB.seen === 'string' && dB.seen.indexOf('3 条发现') >= 0 && dB.seen.indexOf('1 条仍在报') >= 0, dB.seen);
1496
+ check('dutyBriefing explains WHY nothing was executed', typeof dB.action === 'string' && dB.action.indexOf('未执行任何动作') >= 0 && dB.action.indexOf('1 条处置建议') >= 0, dB.action);
1497
+ check('dutyBriefing prefers the server-computed trend verdict', dutyBriefing(mkDutyReport([], 'warning', '连续 2 次体检都在变差')).verdict.indexOf('连续 2 次') >= 0);
1498
+ const dSame1 = dutyBriefing(mkDutyReport([], 'ok'), { at: '2026-11-12T22:00:00.000Z', trigger: 'scheduled' });
1499
+ const dSame2 = dutyBriefing(mkDutyReport([], 'ok'), { at: '2026-11-12T22:00:00.000Z', trigger: 'scheduled' });
1500
+ check('dutyBriefing is deterministic for identical input', JSON.stringify(dSame1) === JSON.stringify(dSame2));
1501
+ const manyF = Array.from({ length: 9 }, (_, i) => ({ id: 'f' + i, severity: 'info', recommendation: '建议' + i }));
1502
+ check('dutyBriefing caps the prepared suggestions at 6', dutyBriefing(mkDutyReport(manyF)).suggestions.length === 6);
1503
+
1504
+ // 服务端:周期当班 + 重入/去抖 + 三处返回体带 duty + 绝不自动执行
1505
+ check('L0 duty loop is driven by setInterval', dutyIndex.indexOf('setInterval(dutyTick') >= 0);
1506
+ // 裸定时器在 fiber 被 HMR 换掉 / 停用插件时不会停,每重载一次多一个永不停止的当班环。
1507
+ // 钉死它挂在 ctx.effect 上并且 dispose 里 clearInterval。
1508
+ // 用正则钉「完整 label」:indexOf 会被 'L0 duty-DISABLED' 之类前缀命中而永远绿(v0.9.15 的教训)。
1509
+ const dutyEffectLabel = /'dsh-steward: L0 duty'/.exec(dutyIndex);
1510
+ const dutyEffectAt = dutyEffectLabel ? dutyEffectLabel.index : -1;
1511
+ const dutyEffectBlock = dutyEffectAt >= 0 ? dutyIndex.slice(dutyIndex.lastIndexOf('ctx.effect(', dutyEffectAt), dutyEffectAt) : '';
1512
+ check('the duty timer is owned by a ctx.effect with a clearInterval dispose',
1513
+ dutyEffectAt >= 0
1514
+ && dutyEffectBlock.indexOf('setInterval(dutyTick') >= 0
1515
+ && dutyEffectBlock.indexOf('return () =>') >= 0
1516
+ && dutyEffectBlock.indexOf('clearInterval(dutyTimer)') >= 0,
1517
+ dutyEffectBlock.slice(0, 140));
1518
+ check('the duty interval follows ui.autoScanMinutes', dutyIndex.indexOf("settingValue(currentSettings(), 'ui.autoScanMinutes')") >= 0 && dutyIndex.indexOf('dutyIntervalMs') >= 0);
1519
+ check('the duty loop has a re-entry guard', dutyIndex.indexOf('if (dutyRunning) return null') >= 0);
1520
+ check('the duty loop has a debounce', dutyIndex.indexOf('Date.now() - dutyLastAt < 1000') >= 0);
1521
+ const rdStart = dutyIndex.indexOf('async function runDuty(');
1522
+ const rdEnd = dutyIndex.indexOf('const hub = createActionHub(', rdStart);
1523
+ const rdBody = rdStart >= 0 && rdEnd > rdStart ? dutyIndex.slice(rdStart, rdEnd) : '';
1524
+ check('runDuty reuses the read-only report and never calls plan/execute', rdBody.indexOf("await run('report', {})") >= 0 && rdBody.indexOf('hub.plan(') < 0 && rdBody.indexOf('hub.execute(') < 0, rdBody.slice(0, 160));
1525
+ check('the duty ledger is persisted to duty.jsonl', dutyIndex.indexOf("duty: path.join(dataDir(), 'duty.jsonl')") >= 0 && dutyIndex.indexOf('io.appendJsonl(files.duty, briefing)') >= 0);
1526
+ // readDutyLog 的 split 参数必须真的是「换行符」。写成 '\\n'(双反斜杠)会永远读不出
1527
+ // 分班记录,而静态判据只看得见「有 split」。这里把源码里的字面量求值,钉死它等于一个换行符。
1528
+ const dutySplitLine = dutyIndex.split('\n').find((l) => l.indexOf('raw.split(') >= 0 && l.indexOf('DUTY_LOG_CAP') >= 0) || '';
1529
+ const dsi = dutySplitLine.indexOf('raw.split(') + 'raw.split('.length;
1530
+ const dsArg = dutySplitLine.slice(dsi);
1531
+ const dsQuote = dsArg[0];
1532
+ const dsEnd = dsQuote ? dsArg.indexOf(dsQuote, 1) : -1;
1533
+ const dsExpr = dsEnd > 0 ? dsArg.slice(0, dsEnd + 1) : '';
1534
+ let dsVal = null;
1535
+ try { dsVal = Function('return ' + dsExpr)(); } catch { /* 求值失败就让它红 */ }
1536
+ check('readDutyLog splits on a real newline (runtime-evaluated), not a literal backslash-n', dsVal === '\n' && dsVal.length === 1, JSON.stringify({ dsExpr, len: dsVal && dsVal.length }));
1537
+ check('ping / report / audit all expose duty', (dutyIndex.match(/duty: await dutySnapshot\(\)/g) || []).length === 3);
1538
+ check('the boot checkup goes through runDuty (not a bare report)', dutyIndex.indexOf("runDuty('boot')") >= 0);
1539
+
1540
+ // 面板:当班状态 + 当班台账(服务端算好人话、客户端只渲染)
1541
+ check('the panel shows the duty status line', dutyClient.indexOf('当班 · ') >= 0 && dutyClient.indexOf('cache.duty') >= 0);
1542
+ check('the audit tab shows the duty ledger', dutyClient.indexOf('当班台账 · ') >= 0 && dutyClient.indexOf('data.duty.suggestions') >= 0);
1543
+ check('the panel caches duty from report responses', (dutyClient.match(/cache\.duty = data\.duty/g) || []).length >= 2);
1544
+ check('the duty briefing is computed in report.js, not in client.js', dutyReport.indexOf('export function dutyBriefing(') >= 0 && dutyClient.indexOf('function dutyBriefing(') < 0);
1545
+
1546
+ // 版本与文档
1547
+ check('v0.9.16 version is consistent', JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version === '0.9.16');
1548
+ check('README documents the L0 duty loop', fs.readFileSync(path.join(here, 'README.md'), 'utf8').indexOf('v0.9.16(L0 当班环') >= 0);
1549
+ }
1550
+
1551
+ {
1552
+ // ---- 白色按钮回归:primary 按钮必须反相,绝不能被调用方的 tone 顶掉 ----
1553
+ // C.text 与 C.solid 都映射到 --dsw-alias-label-primary,深色主题下解析成白色;
1554
+ // 一旦 color 用了 tone,primary 就变成白底白字 = 看不见字的纯白矩形。
1555
+ const wbClient = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1556
+ check('Btn forces the inverted foreground for primary buttons',
1557
+ wbClient.indexOf('color: props.primary && !props.disabled ? C.on : (props.tone || C.text)') >= 0);
1558
+ check('the old tone-wins colour expression is gone',
1559
+ wbClient.indexOf('color: props.tone || (props.primary') < 0);
1560
+ check('no primary button call site passes a tone',
1561
+ (wbClient.match(/primary:\s*true\s*,\s*tone:/g) || []).length === 0);
1562
+ check('the finding go-to-action button stays a plain primary button',
1563
+ wbClient.indexOf('h(Btn, { onClick: props.onGo, primary: true }') >= 0);
1564
+ }
1565
+
1566
+ {
1567
+ // — v0.9.16(未发布):升级利弊分析 (m13463) + 免费通道运行期健康 (m13398) —
1568
+ const AU = analyzeUpgrades;
1569
+ const PKG_HI = { 'dsh-x': { latest: '2.0.0', versions: ['1.0.0', '2.0.0'], engines: { dsh: '^0.3.0' } } };
1570
+ const hi = AU([{ name: 'dsh-x', installed: '1.0.0', latest: '2.0.0' }], PKG_HI, '0.2.2-rc.1');
1571
+ check('upgrade advice: a host-version mismatch is high risk', hi.rows[0].risk === 'high', JSON.stringify(hi.rows[0]));
1572
+ check('upgrade advice: high risk names the boot consequence', hi.rows[0].reasons.join(' ').includes('起不来'), JSON.stringify(hi.rows[0].reasons));
1573
+ const md = AU([{ name: 'dsh-y', installed: '1.0.0', latest: '2.0.0-beta.1' }], { 'dsh-y': { latest: '2.0.0-beta.1', versions: ['1.0.0', '2.0.0-beta.1'] } }, '0.2.2-rc.1');
1574
+ check('upgrade advice: a prerelease target is medium risk', md.rows[0].risk === 'medium', JSON.stringify(md.rows[0]));
1575
+ const sk = AU([{ name: 'dsh-z', installed: '1.0.0', latest: '9.0.0' }], { 'dsh-z': { latest: '9.0.0', versions: ['1.0.0', '2.0.0', '3.0.0', '4.0.0', '5.0.0', '6.0.0', '9.0.0'] } }, '0.2.2-rc.1');
1576
+ check('upgrade advice: skipping >=5 releases is medium risk', sk.rows[0].risk === 'medium' && sk.rows[0].skipped >= 5, JSON.stringify(sk.rows[0]));
1577
+ const lo = AU([{ name: 'dsh-w', installed: '1.0.0', latest: '1.0.1' }], { 'dsh-w': { latest: '1.0.1', versions: ['1.0.0', '1.0.1'] } }, null);
1578
+ check('upgrade advice: a plain small step is low risk', lo.rows[0].risk === 'low', JSON.stringify(lo.rows[0]));
1579
+ check('upgrade advice: text states benefits without inventing a changelog', hi.text.includes('优点') && hi.text.includes('不编造具体修复内容'), hi.text);
1580
+ check('upgrade advice: text states the boot risk', hi.text.includes('boot'), hi.text);
1581
+ check('upgrade advice: recommendation is read-only, no auto-upgrade', hi.recommendation.includes('不代升级') && hi.recommendation.includes('逐个'), hi.recommendation);
1582
+ check('upgrade advice: toast words are written by the server', hi.toastTitle.includes('风险') && hi.toastBody.includes('优点'), hi.toastTitle);
1583
+ const hi2 = AU([{ name: 'dsh-x', installed: '1.0.0', latest: '2.0.0' }], PKG_HI, '0.2.2-rc.1');
1584
+ check('upgrade advice: fingerprint is a stable 8-hex digest', /^[0-9a-f]{8}$/.test(hi.fingerprint) && hi.fingerprint === hi2.fingerprint, hi.fingerprint);
1585
+ const fpA = AU([{ name: 'a', installed: '1.0.0', latest: '2.0.0' }, { name: 'b', installed: '1.0.0', latest: '3.0.0' }], {}, '0.2.2-rc.1');
1586
+ const fpB = AU([{ name: 'b', installed: '1.0.0', latest: '3.0.0' }, { name: 'a', installed: '1.0.0', latest: '2.0.0' }], {}, '0.2.2-rc.1');
1587
+ check('upgrade advice: fingerprint is order-independent', fpA.fingerprint === fpB.fingerprint, fpA.fingerprint + ' vs ' + fpB.fingerprint);
1588
+ const emp = AU([], {}, null);
1589
+ check('upgrade advice: no outdated items yields count 0 but a fingerprint', emp.outdatedCount === 0 && /^[0-9a-f]{8}$/.test(emp.fingerprint), emp.fingerprint);
1590
+ const AVAIL = JSON.stringify({ version: 1, at: 0, results: {
1591
+ 'model-a': { state: 'available', detail: 'OpenCode free inference requires a local Bun executable', at: '2026-11-13T12:00:00Z', stale: true },
1592
+ 'model-b': { state: 'unknown', detail: 'upstream request failed', at: '2026-11-13T13:00:00Z', stale: true },
1593
+ 'model-c': { state: 'available', at: '2026-11-13T14:00:00Z', stale: true },
1594
+ } });
1595
+ const flIo = { readDir: () => [], stat: () => ({ __error: 'x' }), readText: (f) => (f.endsWith('availability.json') ? AVAIL : { __error: 'no' }) };
1596
+ const fl = collectFreeLaneHealth({ dshHome: 'H' }, flIo);
1597
+ check('free lane: contradictory available-but-failing is caught', fl.available && fl.contradictory.length === 1 && fl.contradictory[0].name === 'model-a', JSON.stringify(fl));
1598
+ check('free lane: both detail-bearing models are collected as failing', fl.failing.length === 2, JSON.stringify(fl.failing));
1599
+ check('free lane: allStale is true when every entry is stale', fl.allStale === true && fl.checked === 3, JSON.stringify(fl));
1600
+ check('free lane: newestAt is the max probe timestamp', fl.newestAt === '2026-11-13T14:00:00Z', String(fl.newestAt));
1601
+ const flMiss = collectFreeLaneHealth({ dshHome: 'H' }, { readText: () => ({ __error: 'ENOENT' }) });
1602
+ check('free lane: a missing private ledger is a silent, observable skip', flMiss.available === false && flMiss.failing.length === 0, JSON.stringify(flMiss));
1603
+ function flDataIo(availText) {
1604
+ const base = dataIo();
1605
+ return { readDir: base.readDir, stat: base.stat,
1606
+ readText(file) { return file.endsWith('availability.json') ? availText : base.readText(file); } };
1607
+ }
1608
+ const flReport = collectReport({
1609
+ now: new Date('2026-11-09T13:00:00Z'), bootedAt: new Date('2026-11-09T00:00:00Z'),
1610
+ env: { APPDATA: 'C:\\\\Users\\\\demo\\\\AppData\\\\Roaming', DSH_HOME: 'C:\\\\Users\\\\demo\\\\.dsh' },
1611
+ platform: 'win32', profileDirs: ['C:\\\\p'], io: flDataIo(AVAIL),
1612
+ });
1613
+ check('free lane: report surfaces a read-only info finding', flReport.findings.some((f) => f.id === 'free-lane-health'), JSON.stringify(flReport.errors));
1614
+ check('free lane: an optional signal adds NO coverage source', flReport.coverage.sourcesTotal === 7, JSON.stringify(flReport.coverage));
1615
+ check('free lane: reading the private ledger adds no collection error', !flReport.errors.some((e) => String(e.step || '').includes('free-lane')), JSON.stringify(flReport.errors));
1616
+ check('free lane: report body exposes freeLane for observability', !!(flReport.freeLane && flReport.freeLane.available === true), JSON.stringify(flReport.freeLane));
1617
+ check('free lane: with no ledger the report shows a skip, not a finding', !!(dataReport.freeLane && dataReport.freeLane.available === false) && !dataReport.findings.some((f) => f.id === 'free-lane-health'), JSON.stringify(dataReport.freeLane));
1618
+ check('upgrade advice: report.updates.advice is present with a count', !!(flReport.updates && flReport.updates.advice && flReport.updates.advice.outdatedCount === 2), JSON.stringify(flReport.updates && flReport.updates.advice));
1619
+ const pu = flReport.findings.find((f) => f.id === 'plugin-updates');
1620
+ check('upgrade advice: plugin-updates detail carries the analysis', !!(pu && pu.detail.includes('优点') && pu.detail.includes('不编造')), pu && pu.detail);
1621
+ check('upgrade advice: plugin-updates recommendation came from advice', !!(pu && pu.recommendation.includes('不代升级')), pu && pu.recommendation);
1622
+ const vbClient = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1623
+ check('upgrade toast: client reads report.updates.advice', vbClient.includes('report.updates.advice') && vbClient.includes("'version-' + ua.fingerprint"));
1624
+ check('upgrade toast: the version toast is gated and de-duplicated', vbClient.includes("toastConfig.mode !== 'off'") && vbClient.includes('!toastedIds.has(vkey)'));
1625
+ const vbReport = fs.readFileSync(path.join(here, 'lib', 'report.js'), 'utf8');
1626
+ check('upgrade advice: collectUpdates takes hostVersion and returns advice', vbReport.includes('export function collectUpdates(paths, io, errors, hostVersion)') && vbReport.includes('analyzeUpgrades(outdated, packages, hostVersion)'));
1627
+ check('free lane: reads only the one private ledger, no error/coverage key', vbReport.includes("'our-free-model', 'availability.json'") && !vbReport.includes("step: 'free-lane'"));
1628
+ }
1629
+
1630
+ // — v0.9.16(未发布)右侧栏 tab 正文修复:静态 + 运行期判据 —
1631
+ {
1632
+ const rightSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1633
+
1634
+ check('right tab body is registered at the top level of mountRight',
1635
+ rightSrc.includes("rightDisposers.push(ctx.slots.inject('sidebar.right.pane.tab'") &&
1636
+ !rightSrc.includes('const disposers = [];'));
1637
+ check('mountRight has an idempotent guard (rightMounted)',
1638
+ rightSrc.includes('if (rightMounted) return;'));
1639
+ check('toast overlay is registered via its own ctx.effect, not the entry disposer list',
1640
+ rightSrc.includes("'dsh-steward: toast overlay'") &&
1641
+ !rightSrc.includes("currentDisposers.push(ctx.slots.inject('shell.overlay'"));
1642
+ check('scoped ctx effect owns the right tab type lifecycle',
1643
+ rightSrc.includes('c.effect(() => {') && rightSrc.includes("'dsh-steward: right tab type'"));
1644
+ check('ui.entry keeps its three values and per-value mount decisions',
1645
+ rightSrc.includes("entry !== 'right'") && rightSrc.includes("entry !== 'left'") &&
1646
+ rightSrc.includes("v === 'left' || v === 'right' || v === 'both'"));
1647
+ }
1648
+
1649
+ // 运行期:把真正的 apply() body 抠出来,用假 Cordis ctx 跑一遍「both → settings=right」重挂,
1650
+ // 数一数右侧正文槽位到底被注册/拆掉了几次。静态判据看不出的二次重挂竞态,这里能看出来。
1651
+ await (async () => {
1652
+ let R = { error: null, bodyCount: -1, bodyLive: false, toastCount: -1, toastLive: false, leftLive: -1 };
1653
+ try {
1654
+ const src = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
1655
+ const head = ' apply(ctx) {';
1656
+ const tail = ' },' + String.fromCharCode(10) + ' };' + String.fromCharCode(10) + ' },' + String.fromCharCode(10) + '});';
1657
+ const hs = src.indexOf(head);
1658
+ const ts = src.lastIndexOf(tail);
1659
+ if (hs < 0 || ts <= hs) throw new Error('apply body anchors not found');
1660
+ const bodyText = src.slice(hs + head.length, ts);
1661
+
1662
+ const regs = [];
1663
+ const live = new Set();
1664
+ const slots = {
1665
+ inject: (name, factory) => {
1666
+ const reg = factory();
1667
+ return () => { live.delete(reg); };
1668
+ },
1669
+ register: (opts) => {
1670
+ const reg = { slot: opts && opts.name, key: opts && (opts.key || opts.id) };
1671
+ regs.push(reg);
1672
+ live.add(reg);
1673
+ return reg;
1674
+ },
1675
+ };
1676
+ const scoped = {
1677
+ slots,
1678
+ sidebarRightTabs: { register: () => () => {} },
1679
+ effect: (fn) => { fn(); return () => {}; },
1680
+ get: () => undefined,
1681
+ };
1682
+ const ctx = {
1683
+ slots,
1684
+ effect: (fn) => { fn(); return () => {}; },
1685
+ inject: (deps, cb) => { cb(scoped); return () => {}; },
1686
+ get: () => undefined,
1687
+ };
1688
+ const call = () => Promise.resolve({ settings: { ui: { entry: 'right' } } });
1689
+ const prelude = 'var ENTRY_APPLIER = null; var PANEL_NAVIGATOR = null; var PANEL_ACK = null;' + String.fromCharCode(10);
1690
+ const run = new Function(
1691
+ 'ctx', 'PANEL_ID', 'Panel', 'PanelIcon', 'h', 'ToastHost', 'call',
1692
+ prelude + bodyText
1693
+ );
1694
+ run(ctx, 'dsh-steward', function () {}, function () {}, function () {}, function () {}, call);
1695
+ await new Promise((resolve) => setTimeout(resolve, 0));
1696
+
1697
+ const bodies = regs.filter((r) => r.slot === 'sidebar.right.pane.tab' && r.key === 'dsh-steward');
1698
+ const toasts = regs.filter((r) => r.slot === 'shell.overlay');
1699
+ const left = regs.filter((r) => r.slot === 'sidebar.panellist' || r.slot === 'main');
1700
+ R.bodyCount = bodies.length;
1701
+ R.bodyLive = bodies.length === 1 && live.has(bodies[0]);
1702
+ R.toastCount = toasts.length;
1703
+ R.toastLive = toasts.length === 1 && live.has(toasts[0]);
1704
+ R.leftLive = left.filter((r) => live.has(r)).length;
1705
+ } catch (e) {
1706
+ R.error = String(e);
1707
+ }
1708
+ const why = R.error || JSON.stringify(R);
1709
+ check('runtime: exactly one right-tab body registration survives a settings-driven remount',
1710
+ !R.error && R.bodyCount === 1, why);
1711
+ check('runtime: the right-tab body stays live after the remount',
1712
+ !R.error && R.bodyLive === true, why);
1713
+ check('runtime: the toast overlay stays live after the remount',
1714
+ !R.error && R.toastLive === true, why);
1715
+ check('runtime: ui.entry=right drops the left/main entries',
1716
+ !R.error && R.leftLive === 0, why);
1717
+ })();
1477
1718
  console.log('');
1478
1719
  console.log(`RESULT: ${pass} passed, ${fail} failed`);
1479
1720
  process.exit(fail === 0 ? 0 : 1);