@moonquake2004/dsh-doctor 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,6 +74,31 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
74
74
 
75
75
 
76
76
 
77
+ ## Symptom → check quick-start (dsh-diagnose alignment)
78
+
79
+ If you're coming from a symptom (rather than from the machine), these are the checks to run first. Coverage is honest: ✅ = direct offline coverage, ⚠️ = partial (we see the log/profile effects, not the runtime internals), ❌ = gap (runtime-only, no offline probe today).
80
+
81
+ | Symptom family | dsh-doctor checks | What they catch |
82
+ |---|---|---|
83
+ | session log corruption / can't resume | S1, S2, S6, S7, S8, S9, S10 | orphan tool calls, unclosed turns, seq gaps, end-seed replay, unknown event types, zstd single-frame, sourceEventSeqs drift |
84
+ | oversized / cold-start stall | S11 | estimated materialization heap, corrupt-session quarantine |
85
+ | boot failure (UI won't open) | P1–P10, E10 | dangling bundles, id collisions, patch syntax, host shadowing, adapter conflicts, client-service injects, port 3080 |
86
+ | tool registry gaps (tools missing) | P1, P2, P8, P10, P9 | unresolved/conflicting/duplicated tool registrations, client-only service injects |
87
+ | compaction / history unavailable | S10, S6, S8 | sourceEventSeqs not remapped after compaction |
88
+ | agent-loop lifecycle (session stuck "running") | S2, S1, S6 | unclosed turns, orphan tool calls, broken seq |
89
+ | llm retry storms | S6, S11, S2 | retry traffic effects on log integrity/size |
90
+ | token metering off | S11, S1, S2 | metering derives from the event stream |
91
+ | workflow script failures | P7, S6, S8, S1 | patch syntax (boot), workflow event integrity |
92
+ | approval policy pending | S2, S1 | open turns / orphan calls from pending or rejected approvals |
93
+ | credentials resolution | E2, E5, P4 | `.env` shape, storage JSON, `file:` links |
94
+ | web internals | E10, P10, E5 | port, client half, workspace storage |
95
+ | subagent depth | S11, S8 | session size, subagent event types |
96
+ | sandbox denials | E4 | node-pty binary (infra only) — ❌ runtime policy not offline-checkable |
97
+ | approval internals | S2 | ⚠️ runtime policy; only the turn-level effect |
98
+ | credentials internals | E2, E5 | ⚠️ file-level only |
99
+
100
+ The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form of any of these runs, so a symptom tool can consume the verdict directly.
101
+
77
102
  ## Self-update check (v0.2.1, Layer B)
78
103
 
79
104
  The tool also watches its own npm version: each run compares the installed version against `dist-tags.latest` (same 6h TTL cache + offline fallback as the catalog). When a newer release exists it prints a notice and reports `update: { current, latest, available }` in JSON — it never touches your install without being asked.
@@ -101,6 +126,16 @@ The built-in 20 checks are compiled into the tool. The **catalog** is a second,
101
126
 
102
127
  Catalog check results are marked `src: "catalog"` in JSON output and `[目录]` in CLI output.
103
128
 
129
+ ## LLM observer (v0.3.0, Layer C)
130
+
131
+ The third layer closes the loop between field signals and the catalog: **semi-automatic candidate-check proposals** from a diagnostics run, with a human certification gate. Design + details in [`docs/layer-c-observer.md`](docs/layer-c-observer.md).
132
+
133
+ - `dsh-doctor --observe run.json` — cluster the fail/warn signals of a diagnostics run (a `--json` / `--envelope` output, or a directory of JSON files), then draft candidate checks in the catalog schema (deterministic, default `severity: warn`).
134
+ - `--observe-llm "<cmd>"` (or `DSH_DOCTOR_LLM_CMD`) — enrich drafts with an LLM: `cmd` reads the prompt on stdin and writes a JSON reply on stdout. Replies are constrained to the closed probe vocabulary; any parse/schema violation silently falls back to the draft.
135
+ - `--observe-apply proposals.json` — merge **validated** proposals into the local overlay `plugin/checks.local.json` (idempotent). The overlay runs in diagnostics until you certify the check, but is never distributed — certified checks belong in `plugin/checks.json`.
136
+
137
+ Safety invariants: closed probe vocabulary (LLM output is data, never code), proposals default to `warn`, nothing auto-ships, and no external service is required (no `--observe-llm` = deterministic mode).
138
+
104
139
  ## Also installable as a dsh plugin
105
140
 
106
141
  The tool ships as a proper dsh bundle (`plugin/`), so you can run the same checks (20 built-in + catalog rules) from inside the web UI:
package/README.zh.md CHANGED
@@ -67,6 +67,31 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
67
67
  - [zoahdev/dsh-plugin-doctor](https://github.com/zoahdev/dsh-plugin-doctor) —— 发布前插件 bundle 健康检查(manifest/patch/entry/files/build/pack+全新 profile 安装)+ 宿主遮蔽 `profile-shadow` 哨兵(作者/CI 侧)。与本工具的用户侧 profile/session/env 诊断互补;它的 `profile-shadow` 与我们的 P5 从两个方向标记同一个宿主遮蔽前置条件。
68
68
  - [boyin111-1/dsh-doctor](https://github.com/boyin111-1/dsh-doctor) —— 同生态位离线诊断兄弟实现,用同一批坏 fixture 交叉验证。
69
69
 
70
+ ## Symptom → check quick-start (dsh-diagnose alignment)
71
+
72
+ If you're coming from a symptom (rather than from the machine), these are the checks to run first. Coverage is honest: ✅ = direct offline coverage, ⚠️ = partial (we see the log/profile effects, not the runtime internals), ❌ = gap (runtime-only, no offline probe today).
73
+
74
+ | Symptom family | dsh-doctor checks | What they catch |
75
+ |---|---|---|
76
+ | session log corruption / can't resume | S1, S2, S6, S7, S8, S9, S10 | orphan tool calls, unclosed turns, seq gaps, end-seed replay, unknown event types, zstd single-frame, sourceEventSeqs drift |
77
+ | oversized / cold-start stall | S11 | estimated materialization heap, corrupt-session quarantine |
78
+ | boot failure (UI won't open) | P1–P10, E10 | dangling bundles, id collisions, patch syntax, host shadowing, adapter conflicts, client-service injects, port 3080 |
79
+ | tool registry gaps (tools missing) | P1, P2, P8, P10, P9 | unresolved/conflicting/duplicated tool registrations, client-only service injects |
80
+ | compaction / history unavailable | S10, S6, S8 | sourceEventSeqs not remapped after compaction |
81
+ | agent-loop lifecycle (session stuck "running") | S2, S1, S6 | unclosed turns, orphan tool calls, broken seq |
82
+ | llm retry storms | S6, S11, S2 | retry traffic effects on log integrity/size |
83
+ | token metering off | S11, S1, S2 | metering derives from the event stream |
84
+ | workflow script failures | P7, S6, S8, S1 | patch syntax (boot), workflow event integrity |
85
+ | approval policy pending | S2, S1 | open turns / orphan calls from pending or rejected approvals |
86
+ | credentials resolution | E2, E5, P4 | `.env` shape, storage JSON, `file:` links |
87
+ | web internals | E10, P10, E5 | port, client half, workspace storage |
88
+ | subagent depth | S11, S8 | session size, subagent event types |
89
+ | sandbox denials | E4 | node-pty binary (infra only) — ❌ runtime policy not offline-checkable |
90
+ | approval internals | S2 | ⚠️ runtime policy; only the turn-level effect |
91
+ | credentials internals | E2, E5 | ⚠️ file-level only |
92
+
93
+ The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form of any of these runs, so a symptom tool can consume the verdict directly.
94
+
70
95
  ## 自更新检查(v0.2.1,层 B)
71
96
 
72
97
  工具也会盯着自己的 npm 版本:每次运行对比已装版本与 `dist-tags.latest`(与目录同样的 6h TTL 缓存 + 离线回退)。有新版时打印提示、JSON 里报 `update: { current, latest, available }`——**未经你要求绝不改动你的安装**。
@@ -94,6 +119,16 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
94
119
 
95
120
  目录检查的结果在 JSON 输出中标 `src: "catalog"`,CLI 输出标 `[目录]`。
96
121
 
122
+ ## LLM 观察者(v0.3.0,层 C)
123
+
124
+ 第三层把"现场信号 → 目录条目"的回路半自动化:从诊断运行产出**候选检查提案**,人做最后把关(认证门禁不变)。设计与细节见 [`docs/layer-c-observer.md`](docs/layer-c-observer.md)。
125
+
126
+ - `dsh-doctor --observe run.json` —— 聚类诊断运行的 fail/warn 信号(`--json` / `--envelope` 输出,或含 JSON 的目录),按目录 schema 起草候选检查(确定性,默认 `severity: warn`)。
127
+ - `--observe-llm "<cmd>"`(或 `DSH_DOCTOR_LLM_CMD`)—— 用 LLM 富化草稿:`cmd` 从 stdin 收 prompt,stdout 回 JSON。回复被封闭探测词表约束,任何解析/词表违规静默回退草稿。
128
+ - `--observe-apply proposals.json` —— 把**校验通过**的提案并入本地覆盖层 `plugin/checks.local.json`(幂等)。覆盖层参与本地诊断直到你认证该检查,但永不随包分发——认证后的检查应进 `plugin/checks.json`。
129
+
130
+ 安全不变量:封闭探测词表(LLM 输出永远是数据、不是代码)、提案默认 warn、不自动上目录、不依赖任何外部服务(不带 `--observe-llm` 即确定性模式)。
131
+
97
132
  ## 也可作为 dsh 插件安装
98
133
 
99
134
  工具以标准 dsh bundle 形态发布(`plugin/`),可以在 web UI 里跑同样的检查(20 内置 + 目录规则):
package/dsh-doctor.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
  * P7 cordis.patch.yml 结构 lint(#1724:~ insert: 是 YAML null → parsePatchList 崩溃 → UI 打不开;tab 缩进/缺冒号同族)
13
13
  * P8 adapter provider 注册冲突(#1904②:两 bundle 抢注同一 provider → boot 时 DUPLICATE_ADAPTER 崩溃)
14
14
  * P9 ctx.settings 未声明 inject: ['settings'](#1904⑤:先于 settings 就绪激活 → namespace not registered)
15
+ * P10 inject 引用客户端专属服务(#1947:@deepseek-ai/dsh-client-* 服务端永不提供 → Fiber 永久 PENDING → web boot 失败)
16
+ * P11 已装 bundle 的 main 入口产物缺失(#1965:市场装未构建源码树 → ERR_MODULE_NOT_FOUND → boot 崩)
15
17
  * [session]
16
18
  * S1 孤儿 tool_call(#1363:assistant tool_calls 无对应 tool 结果 → INVALID_REQUEST)
17
19
  * S2 未闭合 turn(#466/#1265:turn/start 无 turn/end → 会话永久"运行中")
@@ -49,7 +51,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
49
51
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
50
52
  import { createRequire } from 'node:module';
51
53
  import net from 'node:net';
52
- import { basename, delimiter as PATH_DELIM, dirname, join } from 'node:path';
54
+ import { basename, delimiter as PATH_DELIM, dirname, join, resolve } from 'node:path';
53
55
  import { homedir } from 'node:os';
54
56
  import { pathToFileURL, fileURLToPath } from 'node:url';
55
57
 
@@ -215,7 +217,17 @@ function portOccupierInfo(port) {
215
217
  const pid = parts[1] || '';
216
218
  let dsh = /dsh|deepseek/.test(cmd);
217
219
  if (!dsh && pid) {
218
- try { dsh = /dsh web|deepseek-ai/.test(execFileSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' })); } catch { /* 无法识别则按非 dsh 处理 */ }
220
+ try {
221
+ dsh = /dsh web|deepseek-ai|harness/.test(execFileSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' }));
222
+ } catch { /* ps 不可用(权限/平台)→ 走 lsof 兜底 */ }
223
+ if (!dsh) {
224
+ try {
225
+ // 兜底:ps 命令串可能不含连续 "dsh web"(npx/pnpm 安装形态),改用 lsof 的 cwd/txt 路径识别 harness 安装。
226
+ // 只认真实安装签名(npx 缓存 / @deepseek-ai 包目录),避免把任意含 "dsh" 路径段的工作目录误判为 dsh。
227
+ const lsofP = execFileSync('lsof', ['-p', pid], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
228
+ dsh = /\.npm\/_npx\/|node_modules\/@deepseek-ai\//.test(lsofP);
229
+ } catch { /* 无法识别则按非 dsh 处理 */ }
230
+ }
219
231
  }
220
232
  return { pid, cmd, dsh };
221
233
  } catch { return null; }
@@ -455,6 +467,18 @@ function checkProfile(name) {
455
467
  report('profile', 'P8', true, '无 adapter provider 注册冲突', undefined);
456
468
  }
457
469
 
470
+ // 提取 bundle 构建产物里声明的全部 inject 依赖名(模块 inject + ctx.inject;bundle 可能混入内部模块的 inject)
471
+ const bundleInjectDecls = (all) => {
472
+ const declared = [];
473
+ for (const m of all.matchAll(/inject\s*=\s*\[([^\]]*)\]/gs)) {
474
+ declared.push(...[...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]));
475
+ }
476
+ for (const m of all.matchAll(/ctx\.inject\s*\(\s*\[([^\]]*)\]/g)) {
477
+ declared.push(...[...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]));
478
+ }
479
+ return declared.filter((v, i) => declared.indexOf(v) === i);
480
+ };
481
+
458
482
  // P9:ctx.settings/ctx.get('settings') 未声明 settings 依赖(#1904⑤:先于 settings 就绪激活 → namespace not registered)
459
483
  // 注意边界:sctx.settings 不算(sctx 是别的变量);ctx.inject(["settings"], cb) 运行时声明算满足
460
484
  const injectIssues = [];
@@ -463,21 +487,36 @@ function checkProfile(name) {
463
487
  const all = files.map(readJs).join('\n');
464
488
  const usesSettings = /(?<![A-Za-z0-9_$])ctx\.(?:get\(\s*['"]settings['"]\s*\)|settings\b)/.test(all);
465
489
  if (!usesSettings) continue;
466
- const declared = [];
467
- // 收集全部 inject 声明(bundle 里可能混有内部模块的 inject;任一含 settings 即满足)
468
- for (const m of all.matchAll(/inject\s*=\s*\[([^\]]*)\]/gs)) {
469
- declared.push(...[...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]));
470
- }
471
- for (const m of all.matchAll(/ctx\.inject\s*\(\s*\[([^\]]*)\]/g)) {
472
- declared.push(...[...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]));
473
- }
474
- const uniq = declared.filter((v, i) => declared.indexOf(v) === i);
490
+ const uniq = bundleInjectDecls(all);
475
491
  if (!uniq.includes('settings')) {
476
492
  injectIssues.push(`${b}(用 ctx.settings 但 settings 依赖未声明${uniq.length ? `,全部 inject: [${uniq.join(', ')}]` : ',未找到任何 inject 声明'})`);
477
493
  }
478
494
  }
479
495
  if (injectIssues.length) report('profile', 'P9', false, `插件用 ctx.settings 但未声明 settings 依赖(#1904⑤:激活时 settings 可能未就绪 → namespace not registered): ${injectIssues.join('; ')}`, '在插件代码加 export const inject = ["settings"](或对可选服务做 undefined 处理)');
480
496
  else report('profile', 'P9', true, 'bundle 的 ctx.settings 用法均声明了 settings 依赖(模块 inject 或 ctx.inject)', undefined);
497
+
498
+ // P10:inject 引用客户端专属服务(@deepseek-ai/dsh-client-*)→ 服务端永不提供 → Fiber 永久 PENDING → web boot 失败(#1947)
499
+ const clientInjectIssues = [];
500
+ for (const [b, d] of bundleDirs) {
501
+ const all = collectJsFiles(d).map(readJs).join('\n');
502
+ const clientDeps = bundleInjectDecls(all).filter((n) => /^(@deepseek-ai\/)?dsh-client-/.test(n));
503
+ if (clientDeps.length) clientInjectIssues.push(`${b}(inject 引用客户端专属服务: ${clientDeps.join(', ')})`);
504
+ }
505
+ if (clientInjectIssues.length) report('profile', 'P10', false, `插件 inject 引用客户端专属服务(服务端 cordis 树永不提供 → Fiber 永久 PENDING → web boot 失败,#1947): ${clientInjectIssues.join('; ')}`, '客户端服务不能作为服务端插件依赖:把相关功能移到插件 client 半(package.json 的 dsh.client.inject),或删除该 inject');
506
+ else report('profile', 'P10', true, '无客户端专属服务注入', undefined);
507
+
508
+ // P11:已装 bundle 的 main 入口产物缺失(#1965:市场把未构建源码树当插件装 → ERR_MODULE_NOT_FOUND → boot 崩)
509
+ const entryIssues = [];
510
+ for (const [b, d] of bundleDirs) {
511
+ let main;
512
+ try { main = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).main; } catch { continue; }
513
+ if (typeof main !== 'string' || !main.endsWith('.js')) continue;
514
+ if (!existsSync(join(d, main))) {
515
+ entryIssues.push(`${b}(main=${main} 但产物缺失——未构建的源码树,或装错了仓库根而非 monorepo 子包)`);
516
+ }
517
+ }
518
+ if (entryIssues.length) report('profile', 'P11', false, `已装 bundle 的 main 入口缺失(#1965:市场装源码不跑构建 → ERR_MODULE_NOT_FOUND → dsh web boot 崩溃): ${entryIssues.join('; ')}`, '在插件目录跑构建(pnpm install && pnpm run build 产出 main 指向的文件),或改用打包好的 npm 包安装;monorepo 插件需装子包(dsh-market #18 同族)');
519
+ else report('profile', 'P11', true, '已装 bundle 的 main 入口产物均在', undefined);
481
520
  }
482
521
 
483
522
  /* ================= session ================= */
@@ -716,36 +755,52 @@ function bundledCatalog() {
716
755
  try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { schemaVersion: 1, checks: [] }; }
717
756
  }
718
757
 
758
+ /** 本地覆盖层(层 C 观察者 --observe-apply 写入):合法则追加,非法/缺失 → []。 */
759
+ function localOverlay(path) {
760
+ const p = path ?? fileURLToPath(new URL('./checks.local.json', import.meta.url));
761
+ try { const d = JSON.parse(readFileSync(p, 'utf8')); return validCatalog(d) ? d.checks : []; } catch { return []; }
762
+ }
763
+
719
764
  function validCatalog(data) {
720
765
  return !!data && data.schemaVersion === 1 && Array.isArray(data.checks);
721
766
  }
722
767
 
723
- /** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本。 */
724
- async function loadCatalog({ noRemote = false, fetchImpl, home = HOME } = {}) {
768
+ /** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本;末尾合并本地覆盖层。 */
769
+ async function loadCatalog({ noRemote = false, fetchImpl, home = HOME, localPath } = {}) {
725
770
  const bundled = bundledCatalog();
726
- if (noRemote || typeof fetchImpl !== 'function') return { checks: bundled.checks, source: 'bundled' };
727
- const cachePath = join(home, '.cache', 'dsh-doctor', 'checks.json');
728
- const readCache = () => { if (!existsSync(cachePath)) return null; try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return validCatalog(d) ? d : null; } catch { return null; } };
729
- try {
730
- const cached = readCache();
731
- if (cached && Date.now() - statSync(cachePath).mtimeMs < CATALOG_TTL_MS) return { checks: cached.checks, source: 'cache' };
732
- } catch { /* 回退 */ }
733
- try {
734
- const ac = new AbortController();
735
- const timer = setTimeout(() => ac.abort(), 3000);
736
- const res = await fetchImpl(REMOTE_CATALOG_URL, { signal: ac.signal });
737
- clearTimeout(timer);
738
- if (res && res.ok) {
739
- const data = await res.json();
740
- if (validCatalog(data)) {
741
- try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(data, null, 2)); } catch { /* 缓存写入失败不影响本次运行 */ }
742
- return { checks: data.checks, source: 'remote' };
743
- }
771
+ let base;
772
+ if (noRemote || typeof fetchImpl !== 'function') {
773
+ base = { checks: bundled.checks, source: 'bundled' };
774
+ } else {
775
+ const cachePath = join(home, '.cache', 'dsh-doctor', 'checks.json');
776
+ const readCache = () => { if (!existsSync(cachePath)) return null; try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return validCatalog(d) ? d : null; } catch { return null; } };
777
+ try {
778
+ const cached = readCache();
779
+ if (cached && Date.now() - statSync(cachePath).mtimeMs < CATALOG_TTL_MS) base = { checks: cached.checks, source: 'cache' };
780
+ } catch { /* 回退 */ }
781
+ if (!base) {
782
+ try {
783
+ const ac = new AbortController();
784
+ const timer = setTimeout(() => ac.abort(), 3000);
785
+ const res = await fetchImpl(REMOTE_CATALOG_URL, { signal: ac.signal });
786
+ clearTimeout(timer);
787
+ if (res && res.ok) {
788
+ const data = await res.json();
789
+ if (validCatalog(data)) {
790
+ try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(data, null, 2)); } catch { /* 缓存写入失败不影响本次运行 */ }
791
+ base = { checks: data.checks, source: 'remote' };
792
+ }
793
+ }
794
+ } catch { /* 离线/超时 → 回退 */ }
744
795
  }
745
- } catch { /* 离线/超时 → 回退 */ }
746
- const stale = readCache();
747
- if (stale) return { checks: stale.checks, source: 'cache-stale' };
748
- return { checks: bundled.checks, source: 'bundled' };
796
+ if (!base) {
797
+ const stale = readCache();
798
+ base = stale ? { checks: stale.checks, source: 'cache-stale' } : { checks: bundled.checks, source: 'bundled' };
799
+ }
800
+ }
801
+ const local = localOverlay(localPath);
802
+ if (!local.length) return base;
803
+ return { checks: [...base.checks, ...local], source: base.source === 'bundled' ? 'bundled+local' : `${base.source}+local` };
749
804
  }
750
805
 
751
806
  function expandPath(tpl, ctx) {
@@ -975,10 +1030,43 @@ export function runUpdate() {
975
1030
  }
976
1031
 
977
1032
  /* ================= main ================= */
1033
+ const flagValue = (name) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : undefined; };
978
1034
  const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
979
1035
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
980
1036
 
981
1037
  async function run() {
1038
+ // 层 C 观察者(--observe / --observe-apply):独立子命令,跑完即退出,不执行常规检查
1039
+ const observeArg = flagValue('--observe');
1040
+ const observeApplyArg = flagValue('--observe-apply');
1041
+ const llmCmd = process.argv.includes('--observe-llm') ? flagValue('--observe-llm') : process.env.DSH_DOCTOR_LLM_CMD ?? null;
1042
+ if (observeArg || observeApplyArg) {
1043
+ const { runObserver, applyProposals, writeLocalOverlay, readLocalOverlay } = await import('./observer.mjs');
1044
+ try {
1045
+ if (observeApplyArg) {
1046
+ const raw = JSON.parse(readFileSync(resolve(observeApplyArg), 'utf8'));
1047
+ const list = Array.isArray(raw) ? raw : raw.proposals ?? [];
1048
+ const overlayPath = join(dirname(fileURLToPath(import.meta.url)), 'checks.local.json');
1049
+ // 覆盖层只追加新提案(loadCatalog 会 base + local 合并),且对已存在覆盖层幂等
1050
+ const existing = readLocalOverlay(overlayPath);
1051
+ const { catalog: merged, applied, rejected } = applyProposals({ schemaVersion: 1, checks: existing }, list);
1052
+ if (applied.length) {
1053
+ writeLocalOverlay(overlayPath, {
1054
+ schemaVersion: 1,
1055
+ description: 'Layer C 观察者本地覆盖层——未认证提案,不随包分发;认证通过后请合并进 checks.json 并删除本文件。',
1056
+ checks: merged.checks,
1057
+ });
1058
+ }
1059
+ console.log(JSON.stringify({ ok: true, written: applied.length ? overlayPath : null, applied: applied.map((p) => p.id), rejected }, null, 2));
1060
+ process.exit(0);
1061
+ }
1062
+ const res = await runObserver({ path: observeArg, existingChecks: bundledCatalog().checks, llmCmd });
1063
+ console.log(JSON.stringify(res, null, 2));
1064
+ process.exit(0);
1065
+ } catch (e) {
1066
+ console.error(`观察者失败: ${e.message}`);
1067
+ process.exit(1);
1068
+ }
1069
+ }
982
1070
  try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
983
1071
  try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
984
1072
  try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
@@ -1019,6 +1107,7 @@ async function run() {
1019
1107
  const exitCode = summary.fail > 0 ? 2 : summary.warn > 0 ? 1 : 0;
1020
1108
  console.log(JSON.stringify({
1021
1109
  schema: 'dsh-doctor/v1',
1110
+ tool: 'dsh-doctor',
1022
1111
  generatedAt: new Date().toISOString(),
1023
1112
  profile: profileArg,
1024
1113
  exitCode,
package/observer.mjs ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Layer C — 半自动 LLM 观察者(MVP)
3
+ *
4
+ * 纯函数模块:从诊断运行现场信号 → 聚类 → 确定性草稿 →(可选)LLM 富化
5
+ * → 人审补全 → 校验合并进本地覆盖层。安全不变量见 docs/layer-c-observer.md:
6
+ * 1. 封闭探测词表(与引擎同表),LLM 输出永远是数据、永远不能成为代码;
7
+ * 2. 候选默认 severity=warn,不直接制造 error 告警;
8
+ * 3. --observe-apply 只合并校验通过的提案,且只写本地覆盖层,不自动进目录;
9
+ * 4. LLM 未配置/超时/输出非法 → 静默回退确定性草稿,工具不依赖外部服务。
10
+ *
11
+ * 用法(CLI 接线在 dsh-doctor.mjs):
12
+ * node dsh-doctor.mjs --observe run.json
13
+ * node dsh-doctor.mjs --observe run.json --observe-llm "<cmd 读 stdin 写 stdout>"
14
+ * node dsh-doctor.mjs --observe-apply proposals.json
15
+ */
16
+
17
+ import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { dirname, join, isAbsolute, resolve } from 'node:path';
20
+
21
+ /** 封闭探测词表:type → 该类型必填参数(与 plugin/dsh-doctor.mjs 引擎原语一致)。 */
22
+ export const PROBE_VOCABULARY = {
23
+ 'command-exists': ['cmd'],
24
+ 'path-exists': ['path'],
25
+ 'path-is-dir': ['path'],
26
+ 'path-is-file': ['path'],
27
+ 'json-valid': ['path'],
28
+ 'text-contains': ['path', 'pattern'],
29
+ 'text-not-contains': ['path', 'pattern'],
30
+ 'file-size-above': ['path', 'min'],
31
+ 'glob-count': ['base', 'pattern'],
32
+ 'file-writable': ['path'],
33
+ };
34
+
35
+ const SECTIONS = new Set(['env', 'profile', 'session', 'catalog']);
36
+ const SEVERITIES = new Set(['error', 'warn']);
37
+
38
+ /** 症状 → 探测词表提示映射(确定性草稿用;LLM 富化可改,但必须仍在词表内)。 */
39
+ const PROBE_HINTS = [
40
+ { re: /json/i, type: 'json-valid', hint: '目标文件 JSON 合法性' },
41
+ { re: /path|which|command|命令|不在|未安装|executable|bin/i, type: 'command-exists', hint: '命令/可执行文件在 PATH' },
42
+ { re: /writ|可写|权限|属主|chown|sudo|readonly|只读/i, type: 'file-writable', hint: '文件可写性' },
43
+ { re: /patch|insert|yaml|yml|cordis/i, type: 'text-contains', hint: '文本模式匹配(patch/配置类)' },
44
+ { re: /port|端口|3080|listen/i, type: 'path-exists', hint: '路径存在性(端口/资源占位)' },
45
+ { re: /glob|文件数|count|recursive|目录/i, type: 'glob-count', hint: '目录递归文件计数' },
46
+ ];
47
+
48
+ /** 从任意常见诊断输出形态抽取检查条目:数组 / {checks} / {results}。 */
49
+ export function extractChecks(data) {
50
+ if (Array.isArray(data)) return data;
51
+ if (data && Array.isArray(data.checks)) return data.checks; // envelope(v1)与 plain JSON 都用 checks
52
+ if (data && Array.isArray(data.results)) return data.results;
53
+ return [];
54
+ }
55
+
56
+ /** 检查条目 → 状态:'pass' | 'fail' | 'warn' | 'unknown'(envelope 用 status,plain 用 ok)。 */
57
+ export function checkStatus(c) {
58
+ if (typeof c.status === 'string') return ['pass', 'warn', 'fail'].includes(c.status) ? c.status : 'unknown';
59
+ if (c.ok === false) return 'fail';
60
+ if (c.ok === true) return 'pass';
61
+ return 'unknown';
62
+ }
63
+
64
+ /** 检查条目 → section(envelope 无 section 字段,按 id 前缀推断)。 */
65
+ export function checkSection(c) {
66
+ if (typeof c.section === 'string' && SECTIONS.has(c.section)) return c.section;
67
+ const id = String(c.id ?? c.name ?? '');
68
+ if (/^E/i.test(id)) return 'env';
69
+ if (/^P/i.test(id)) return 'profile';
70
+ if (/^S/i.test(id)) return 'session';
71
+ if (/^C/i.test(id)) return 'catalog';
72
+ return 'env';
73
+ }
74
+
75
+ /** detail 归一化:小写、trim、去空白折叠、去尾标点。聚类键的一部分,保证同义症状合簇。 */
76
+ export function normalizeDetail(text) {
77
+ return String(text ?? '')
78
+ .toLowerCase()
79
+ .trim()
80
+ .replace(/\s+/g, ' ')
81
+ .replace(/[.。!!??;;,,::]+$/g, '')
82
+ .trim();
83
+ }
84
+
85
+ /**
86
+ * 聚类:按 (section, 归一化 detail) 分簇 fail/warn 信号。
87
+ * 返回 [{ section, signature, count, examples: [原始 detail], statuses: Set }]
88
+ */
89
+ export function clusterSignals(data) {
90
+ const clusters = new Map();
91
+ for (const c of extractChecks(data)) {
92
+ const st = checkStatus(c);
93
+ if (st !== 'fail' && st !== 'warn') continue;
94
+ const section = checkSection(c);
95
+ const detail = String(c.detail ?? c.name ?? c.id ?? '');
96
+ const sig = normalizeDetail(detail);
97
+ const key = `${section}|${sig}`;
98
+ if (!clusters.has(key)) {
99
+ clusters.set(key, { section, signature: sig, count: 0, examples: [], statuses: new Set() });
100
+ }
101
+ const cl = clusters.get(key);
102
+ cl.count++;
103
+ cl.statuses.add(st);
104
+ if (cl.examples.length < 3 && !cl.examples.includes(detail)) cl.examples.push(detail);
105
+ }
106
+ return [...clusters.values()].map((c) => ({ ...c, statuses: [...c.statuses] }));
107
+ }
108
+
109
+ /** signature → 短 slug(候选检查 id 用)。 */
110
+ export function slugOf(signature, maxWords = 5) {
111
+ const words = signature.split(' ').filter(Boolean).slice(0, maxWords);
112
+ let slug = words.map((w) => w.replace(/[^a-z0-9]+/g, '-')).join('-').replace(/^-+|-+$/g, '');
113
+ if (!slug) slug = 'signal';
114
+ return slug.slice(0, 48);
115
+ }
116
+
117
+ /** 确定性草稿:症状 → 候选检查骨架(探测参数大概率不全,正是留给 LLM/人补全的点)。 */
118
+ export function draftProposal(cluster, existingIds = [], seq = 0) {
119
+ const hint = PROBE_HINTS.find((h) => h.re.test(cluster.signature)) ?? { type: 'text-contains', hint: '文本模式匹配' };
120
+ const base = `${cluster.section}-${slugOf(cluster.signature)}`;
121
+ let id = `${base}-probe`;
122
+ let n = 0;
123
+ while (existingIds.includes(id)) id = `${base}-${++n + 1}-probe`; // 去重:追加序号
124
+ const p = {
125
+ id,
126
+ section: cluster.section,
127
+ severity: 'warn', // 安全不变量 2:候选默认 warn
128
+ title: `候选检查(观察者 #${seq + 1}):${cluster.signature.slice(0, 48)}`,
129
+ discussion: null,
130
+ anchor: { package: null, symbol: null, train: null },
131
+ probe: skeletonProbe(hint.type),
132
+ detailOk: `通过(待补)——${hint.hint}`,
133
+ detailFail: `命中(待补):${cluster.signature.slice(0, 80)}`,
134
+ fix: '待补:给出可执行修复建议',
135
+ proposedBy: 'observer',
136
+ proposedAt: new Date().toISOString(),
137
+ };
138
+ return p;
139
+ }
140
+
141
+ function skeletonProbe(type) {
142
+ switch (type) {
143
+ case 'command-exists': return { type, cmd: '' };
144
+ case 'path-exists':
145
+ case 'path-is-dir':
146
+ case 'path-is-file':
147
+ case 'json-valid':
148
+ case 'file-writable': return { type, path: '', required: false };
149
+ case 'text-contains':
150
+ case 'text-not-contains': return { type, path: '', pattern: '', flags: '', required: false };
151
+ case 'file-size-above': return { type, path: '', min: 0, required: false };
152
+ case 'glob-count': return { type, base: '', pattern: '', required: false };
153
+ default: return { type };
154
+ }
155
+ }
156
+
157
+ /** 提案校验:词表/必填参数/section/severity/id 唯一性。返回 { ok, errors[] }。 */
158
+ export function validateProposal(p, existingIds = []) {
159
+ const errors = [];
160
+ if (!p || typeof p !== 'object') return { ok: false, errors: ['提案不是对象'] };
161
+ if (typeof p.id !== 'string' || !p.id.trim()) errors.push('id 缺失');
162
+ else if (existingIds.includes(p.id)) errors.push(`id 重复: ${p.id}`);
163
+ if (!SECTIONS.has(p.section)) errors.push(`section 非法: ${p.section}`);
164
+ if (!SEVERITIES.has(p.severity)) errors.push(`severity 非法: ${p.severity}`);
165
+ const t = p.probe?.type;
166
+ if (!PROBE_VOCABULARY[t]) errors.push(`probe.type 不在词表: ${t}`);
167
+ else {
168
+ for (const key of PROBE_VOCABULARY[t]) {
169
+ const v = p.probe[key];
170
+ if (v === undefined || v === null || v === '' || (typeof v === 'number' && Number.isNaN(v))) errors.push(`probe.${key} 缺失(${t} 必填)`);
171
+ }
172
+ }
173
+ if (p.probe?.type === 'file-size-above' && typeof p.probe.min !== 'number') errors.push('probe.min 必须为数字');
174
+ return { ok: errors.length === 0, errors };
175
+ }
176
+
177
+ /** LLM prompt:给定现场信号 + 现有目录(id 清单防撞),要求输出词表内 JSON。 */
178
+ export function renderLLMPrompt(cluster, draft, existingChecks = []) {
179
+ const vocab = Object.keys(PROBE_VOCABULARY).join(', ');
180
+ return [
181
+ '你是 dsh-doctor 的候选检查起草助手(Layer C 观察者)。只输出一个 JSON 对象,不要任何多余文字。',
182
+ '',
183
+ `现场信号:section=${cluster.section},命中 ${cluster.count} 次(${cluster.statuses.join('/')})`,
184
+ `症状详情:${cluster.signature}`,
185
+ `示例:${cluster.examples.map((e) => JSON.stringify(e)).join(' ; ')}`,
186
+ '',
187
+ '输出 JSON 只能含这些键(全部可选,缺省回退草稿):',
188
+ ' title, severity("error"|"warn"), probe({type 必须∈词表: ' + vocab + ', 及该类型必填参数}),',
189
+ ' detailOk, detailFail, fix, anchor({package,symbol,train} 或 null)',
190
+ '',
191
+ `现有目录检查 id(不得撞名):${existingChecks.map((c) => c.id).join(', ') || '(空)'}`,
192
+ '',
193
+ `当前草稿(可全改,probe 参数必须填全才能被应用):${JSON.stringify(draft, null, 2)}`,
194
+ '',
195
+ '只输出 JSON:',
196
+ ].join('\n');
197
+ }
198
+
199
+ /** LLM 回复富化:只采纳词表内字段;任何解析/校验失败 → 回退草稿并附原因。 */
200
+ export function enrichDraft(draft, llmReply) {
201
+ if (!llmReply || typeof llmReply !== 'string') return { ...draft, llm: 'ignored: 无回复' };
202
+ let parsed;
203
+ try {
204
+ parsed = JSON.parse(llmReply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
205
+ } catch {
206
+ return { ...draft, llm: 'ignored: LLM 输出非 JSON' };
207
+ }
208
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) return { ...draft, llm: 'ignored: LLM 输出非对象' };
209
+ const out = { ...draft };
210
+ const reject = (why) => ({ ...draft, llm: `ignored: ${why}` });
211
+ // severity:词表内
212
+ if (parsed.severity !== undefined) {
213
+ if (!SEVERITIES.has(parsed.severity)) return reject(`severity 非法 (${parsed.severity})`);
214
+ out.severity = parsed.severity;
215
+ }
216
+ // probe:type 必须在词表,参数按词表必填收集(缺的保留原草稿值)
217
+ if (parsed.probe !== undefined) {
218
+ if (typeof parsed.probe !== 'object' || !PROBE_VOCABULARY[parsed.probe.type]) return reject(`probe.type 不在词表 (${parsed.probe?.type})`);
219
+ const merged = { ...draft.probe, ...parsed.probe };
220
+ out.probe = merged;
221
+ }
222
+ // 字符串字段
223
+ for (const key of ['title', 'detailOk', 'detailFail', 'fix']) {
224
+ if (typeof parsed[key] === 'string' && parsed[key].trim()) out[key] = parsed[key].trim();
225
+ }
226
+ // anchor:合法对象才采纳
227
+ if (parsed.anchor !== undefined) {
228
+ if (parsed.anchor === null) out.anchor = null;
229
+ else if (typeof parsed.anchor === 'object' && !Array.isArray(parsed.anchor)) {
230
+ out.anchor = { package: parsed.anchor.package ?? null, symbol: parsed.anchor.symbol ?? null, train: parsed.anchor.train ?? null };
231
+ } else return reject('anchor 非法');
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /** 收集现有检查 id(供去重/防撞)。 */
237
+ export function existingIdsOf(checks) {
238
+ return (checks ?? []).map((c) => c.id).filter(Boolean);
239
+ }
240
+
241
+ /**
242
+ * 观察入口:input = 诊断运行 JSON 对象 或 {path}(文件或含 JSON 的目录)。
243
+ * llmCmd 未给 → 跳过 LLM 步(确定性草稿 + prompt 照常输出)。
244
+ * 返回 { generatedAt, source, signals, clusters, proposals }。
245
+ */
246
+ export async function runObserver({ input, path, existingChecks = [], llmCmd = null } = {}) {
247
+ const data = input ?? loadInput(path);
248
+ const clusters = clusterSignals(data);
249
+ const existing = existingIdsOf(existingChecks);
250
+ const proposals = [];
251
+ for (let i = 0; i < clusters.length; i++) {
252
+ const draft = draftProposal(clusters[i], [...existing, ...proposals.map((p) => p.id)], i);
253
+ let final = draft;
254
+ if (llmCmd) {
255
+ const reply = runLLM(llmCmd, renderLLMPrompt(clusters[i], draft, existingChecks));
256
+ final = enrichDraft(draft, reply);
257
+ } else {
258
+ final = { ...draft, prompt: renderLLMPrompt(clusters[i], draft, existingChecks) };
259
+ }
260
+ proposals.push(final);
261
+ }
262
+ return {
263
+ generatedAt: new Date().toISOString(),
264
+ source: typeof path === 'string' ? path : 'input-object',
265
+ signals: clusters.reduce((n, c) => n + c.count, 0),
266
+ clusters: clusters.map((c) => ({ section: c.section, signature: c.signature, count: c.count, examples: c.examples })),
267
+ proposals,
268
+ };
269
+ }
270
+
271
+ function loadInput(path) {
272
+ const p = resolve(path);
273
+ const st = statSync(p);
274
+ if (st.isDirectory()) {
275
+ const out = [];
276
+ for (const f of readdirSync(p)) {
277
+ if (!f.endsWith('.json')) continue;
278
+ try { out.push(JSON.parse(readFileSync(join(p, f), 'utf8'))); } catch { /* 非法 JSON 跳过 */ }
279
+ }
280
+ return { checks: out.flatMap(extractChecks) };
281
+ }
282
+ return JSON.parse(readFileSync(p, 'utf8'));
283
+ }
284
+
285
+ function runLLM(cmd, prompt) {
286
+ try {
287
+ const r = spawnSync(cmd, { input: prompt, encoding: 'utf8', shell: true, timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
288
+ if (r.status !== 0) return null;
289
+ return String(r.stdout ?? '').trim() || null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ /** 校验并合并提案进目录对象(本地覆盖层/人工合并通用)。返回 { catalog, applied, rejected }。 */
296
+ export function applyProposals(catalog, proposals) {
297
+ const base = catalog && Array.isArray(catalog.checks) ? catalog : { schemaVersion: 1, checks: [] };
298
+ const existing = existingIdsOf(base.checks);
299
+ const applied = [];
300
+ const rejected = [];
301
+ for (const p of proposals ?? []) {
302
+ const v = validateProposal(p, [...existing, ...applied.map((a) => a.id)]);
303
+ if (!v.ok) { rejected.push({ id: p?.id ?? '<无id>', errors: v.errors }); continue; }
304
+ applied.push(p);
305
+ }
306
+ return { catalog: { ...base, checks: [...base.checks, ...applied] }, applied, rejected };
307
+ }
308
+
309
+ /** 写本地覆盖层文件(--observe-apply 用)。 */
310
+ export function writeLocalOverlay(filePath, catalog) {
311
+ mkdirSync(dirname(filePath), { recursive: true });
312
+ writeFileSync(filePath, JSON.stringify(catalog, null, 2) + '\n', 'utf8');
313
+ }
314
+
315
+ /** 读本地覆盖层(loadCatalog 合并用);非法/缺失 → []。 */
316
+ export function readLocalOverlay(filePath) {
317
+ try {
318
+ const d = JSON.parse(readFileSync(filePath, 'utf8'));
319
+ return Array.isArray(d.checks) ? d.checks : [];
320
+ } catch {
321
+ return [];
322
+ }
323
+ }
324
+
325
+ export { isAbsolute };
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.2.6",
4
- "description": "Offline diagnostic for DeepSeek Harness — 19 built-in checks + self-updating catalog (Layer A) + self-update check (Layer B); 'Doctor' panel in web UI settings.",
3
+ "version": "0.3.0",
4
+ "description": "Offline diagnostic for DeepSeek Harness — 25 built-in + 5 catalog checks across env/profile/session (Layer A checks-as-data), self-update (Layer B), and a semi-automatic LLM observer (Layer C, --observe); Doctor panel in web UI settings.",
5
5
  "main": "lib/index.js",
6
6
  "files": [
7
- "lib",
8
- "client",
9
- "dsh-doctor.mjs",
7
+ "README.md",
8
+ "README.zh.md",
10
9
  "checks.json",
10
+ "client",
11
11
  "cordis.patch.yml",
12
- "README.md",
13
- "README.zh.md"
12
+ "dsh-doctor.mjs",
13
+ "lib",
14
+ "observer.mjs"
14
15
  ],
15
16
  "peerDependencies": {
16
17
  "@deepseek-ai/cordis": "^4.0.1"
@@ -47,4 +48,4 @@
47
48
  "bin": {
48
49
  "dsh-doctor": "./dsh-doctor.mjs"
49
50
  }
50
- }
51
+ }