@moonquake2004/dsh-doctor 0.2.7 → 0.3.1

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
@@ -69,6 +69,8 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
69
69
 
70
70
  ## Related community tools
71
71
 
72
+ > **dsh-doctor/v1 vocabulary r5 compatible** — drafted by [@ciceroyang](https://github.com/ciceroyang) (ciceroyang/dsh-doctor), reviewed by [@sjh9714](https://github.com/sjh9714) (dsh-win32) and [@moonquake2004](https://github.com/moonquake2004) ([#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719)). Our `node`/`pnpm` checks emit the vocabulary names with r5 semantics (pass/warn/fail/skip; `summary.skip` always present).
73
+
72
74
  - [zoahdev/dsh-plugin-doctor](https://github.com/zoahdev/dsh-plugin-doctor) — pre-publish plugin bundle health checks (manifest/patch/entry/files/build/pack+fresh-profile install) plus a `profile-shadow` tripwire for host-shadowing (author/CI side). Complementary to this tool's user-side profile/session/env diagnostics; its `profile-shadow` and our P5 flag the same host-shadowing precondition from two sides.
73
75
  - [boyin111-1/dsh-doctor](https://github.com/boyin111-1/dsh-doctor) — sibling offline diagnostic, cross-verified against the same broken fixtures.
74
76
 
@@ -126,6 +128,16 @@ The built-in 20 checks are compiled into the tool. The **catalog** is a second,
126
128
 
127
129
  Catalog check results are marked `src: "catalog"` in JSON output and `[目录]` in CLI output.
128
130
 
131
+ ## LLM observer (v0.3.0, Layer C)
132
+
133
+ 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).
134
+
135
+ - `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`).
136
+ - `--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.
137
+ - `--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`.
138
+
139
+ 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).
140
+
129
141
  ## Also installable as a dsh plugin
130
142
 
131
143
  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
@@ -64,6 +64,8 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
64
64
 
65
65
  ## 相关社区工具
66
66
 
67
+ > **dsh-doctor/v1 词汇表 r5 兼容** —— 起草 [@ciceroyang](https://github.com/ciceroyang)(ciceroyang/dsh-doctor),审阅 [@sjh9714](https://github.com/sjh9714)(dsh-win32)与 [@moonquake2004](https://github.com/moonquake2004)([#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719))。我们的 `node`/`pnpm` 检查按 r5 语义输出词汇名(pass/warn/fail/skip;`summary.skip` 常驻)。
68
+
67
69
  - [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
70
  - [boyin111-1/dsh-doctor](https://github.com/boyin111-1/dsh-doctor) —— 同生态位离线诊断兄弟实现,用同一批坏 fixture 交叉验证。
69
71
 
@@ -119,6 +121,16 @@ The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form
119
121
 
120
122
  目录检查的结果在 JSON 输出中标 `src: "catalog"`,CLI 输出标 `[目录]`。
121
123
 
124
+ ## LLM 观察者(v0.3.0,层 C)
125
+
126
+ 第三层把"现场信号 → 目录条目"的回路半自动化:从诊断运行产出**候选检查提案**,人做最后把关(认证门禁不变)。设计与细节见 [`docs/layer-c-observer.md`](docs/layer-c-observer.md)。
127
+
128
+ - `dsh-doctor --observe run.json` —— 聚类诊断运行的 fail/warn 信号(`--json` / `--envelope` 输出,或含 JSON 的目录),按目录 schema 起草候选检查(确定性,默认 `severity: warn`)。
129
+ - `--observe-llm "<cmd>"`(或 `DSH_DOCTOR_LLM_CMD`)—— 用 LLM 富化草稿:`cmd` 从 stdin 收 prompt,stdout 回 JSON。回复被封闭探测词表约束,任何解析/词表违规静默回退草稿。
130
+ - `--observe-apply proposals.json` —— 把**校验通过**的提案并入本地覆盖层 `plugin/checks.local.json`(幂等)。覆盖层参与本地诊断直到你认证该检查,但永不随包分发——认证后的检查应进 `plugin/checks.json`。
131
+
132
+ 安全不变量:封闭探测词表(LLM 输出永远是数据、不是代码)、提案默认 warn、不自动上目录、不依赖任何外部服务(不带 `--observe-llm` 即确定性模式)。
133
+
122
134
  ## 也可作为 dsh 插件安装
123
135
 
124
136
  工具以标准 dsh bundle 形态发布(`plugin/`),可以在 web UI 里跑同样的检查(20 内置 + 目录规则):
package/dsh-doctor.mjs CHANGED
@@ -51,7 +51,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
51
51
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
52
52
  import { createRequire } from 'node:module';
53
53
  import net from 'node:net';
54
- import { basename, delimiter as PATH_DELIM, dirname, join } from 'node:path';
54
+ import { basename, delimiter as PATH_DELIM, dirname, join, resolve } from 'node:path';
55
55
  import { homedir } from 'node:os';
56
56
  import { pathToFileURL, fileURLToPath } from 'node:url';
57
57
 
@@ -108,12 +108,19 @@ function resolveProfile(name) {
108
108
  }
109
109
 
110
110
  /* ================= env ================= */
111
+ /** v1 词汇表 r5(#1719)node 语义:pass = 满足 ^22.19.0 || >=24.0.0(root package.json engines),其余 warn——无民间 fail 阈值。 */
112
+ function nodeInSupportedRange(v) {
113
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v));
114
+ if (!m) return false;
115
+ const major = Number(m[1]); const minor = Number(m[2]);
116
+ return (major === 22 && minor >= 19) || major >= 24;
117
+ }
111
118
  function checkEnv() {
112
119
  if (!wants('env')) return;
113
120
  const find = (cmd) => { for (const w of process.platform === 'win32' ? ['where'] : ['which']) { const r = spawnSync(w, [cmd]); if (r.status === 0) { const p = String(r.stdout).split(/\r?\n/)[0].trim(); if (p) return p; } } return null; };
114
121
  for (const cmd of ['node', 'pnpm', 'zstd']) {
115
122
  const p = find(cmd);
116
- report('env', `E1-${cmd}`, !!p, p ? `${cmd}: ${p}` : `${cmd} 不在 PATH(${cmd === 'node' ? '创建会话会失败 #1270' : cmd === 'pnpm' ? 'dsh plugin 不可用' : '会话日志解压不可用'})`, p ? undefined : `安装 ${cmd} 或加入 PATH`);
123
+ report('env', `E1-${cmd}`, !!p, p ? `${cmd}: ${p}` : `${cmd} 不在 PATH(${cmd === 'node' ? '创建会话会失败 #1270' : cmd === 'pnpm' ? 'dsh plugin 不可用(corepack 可恢复:corepack enable pnpm)' : '会话日志解压不可用'})`, p ? undefined : (cmd === 'pnpm' ? 'corepack enable pnpm 或安装 pnpm 后加入 PATH' : `安装 ${cmd} 或加入 PATH`));
117
124
  }
118
125
  const envFile = join(HOME, '.env');
119
126
  if (existsSync(envFile)) {
@@ -121,7 +128,13 @@ function checkEnv() {
121
128
  report('env', 'E2-env', !isDir, isDir ? `${envFile} 是目录,dsh 启动会报 failed to load .env: EISDIR(#71)` : `${envFile} 正常`, isDir ? '删除或改名该目录' : undefined);
122
129
  }
123
130
  const nv = spawnSync('node', ['-e', 'console.log(process.version)']);
124
- if (nv.status === 0) report('env', 'E3-node', true, `node ${String(nv.stdout).trim()}`, undefined);
131
+ if (nv.status === 0) {
132
+ const version = String(nv.stdout).trim();
133
+ const supported = nodeInSupportedRange(version);
134
+ report('env', 'E3-node', supported,
135
+ supported ? `node ${version}(满足 ^22.19.0 || >=24.0.0,root package.json engines)` : `node ${version} 不在支持范围(^22.19.0 || >=24.0.0)——会话日志读取等能力受限`,
136
+ supported ? undefined : '升级 node 到 ^22.19.0 或 >=24.0.0(root package.json engines,见 #2259)');
137
+ }
125
138
 
126
139
  // E4:node-pty 原生模块完整性(#1219:pty.node 缺失 → dsh web 启动失败)
127
140
  const ptyDirs = [];
@@ -217,7 +230,17 @@ function portOccupierInfo(port) {
217
230
  const pid = parts[1] || '';
218
231
  let dsh = /dsh|deepseek/.test(cmd);
219
232
  if (!dsh && pid) {
220
- try { dsh = /dsh web|deepseek-ai/.test(execFileSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' })); } catch { /* 无法识别则按非 dsh 处理 */ }
233
+ try {
234
+ dsh = /dsh web|deepseek-ai|harness/.test(execFileSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' }));
235
+ } catch { /* ps 不可用(权限/平台)→ 走 lsof 兜底 */ }
236
+ if (!dsh) {
237
+ try {
238
+ // 兜底:ps 命令串可能不含连续 "dsh web"(npx/pnpm 安装形态),改用 lsof 的 cwd/txt 路径识别 harness 安装。
239
+ // 只认真实安装签名(npx 缓存 / @deepseek-ai 包目录),避免把任意含 "dsh" 路径段的工作目录误判为 dsh。
240
+ const lsofP = execFileSync('lsof', ['-p', pid], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
241
+ dsh = /\.npm\/_npx\/|node_modules\/@deepseek-ai\//.test(lsofP);
242
+ } catch { /* 无法识别则按非 dsh 处理 */ }
243
+ }
221
244
  }
222
245
  return { pid, cmd, dsh };
223
246
  } catch { return null; }
@@ -739,42 +762,61 @@ function scanAllSessions() {
739
762
  const REMOTE_CATALOG_URL = 'https://raw.githubusercontent.com/moonquake2004/dsh-doctor/main/plugin/checks.json';
740
763
  const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h:新检查最长 6h 内自动生效
741
764
  const catalogSeverity = new Map(); // catalog 检查 id → severity('error' | 'warn')
765
+ // v1 词汇表 r5 对齐(#1719):E1-pnpm 缺失=warn(corepack 可恢复)、E3-node 越界=warn(EBADENGINE 语义)——均不翻退出码
766
+ catalogSeverity.set('E1-pnpm', 'warn');
767
+ catalogSeverity.set('E3-node', 'warn');
742
768
 
743
769
  function bundledCatalog() {
744
770
  const p = new URL('./checks.json', import.meta.url);
745
771
  try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { schemaVersion: 1, checks: [] }; }
746
772
  }
747
773
 
774
+ /** 本地覆盖层(层 C 观察者 --observe-apply 写入):合法则追加,非法/缺失 → []。 */
775
+ function localOverlay(path) {
776
+ const p = path ?? fileURLToPath(new URL('./checks.local.json', import.meta.url));
777
+ try { const d = JSON.parse(readFileSync(p, 'utf8')); return validCatalog(d) ? d.checks : []; } catch { return []; }
778
+ }
779
+
748
780
  function validCatalog(data) {
749
781
  return !!data && data.schemaVersion === 1 && Array.isArray(data.checks);
750
782
  }
751
783
 
752
- /** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本。 */
753
- async function loadCatalog({ noRemote = false, fetchImpl, home = HOME } = {}) {
784
+ /** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本;末尾合并本地覆盖层。 */
785
+ async function loadCatalog({ noRemote = false, fetchImpl, home = HOME, localPath } = {}) {
754
786
  const bundled = bundledCatalog();
755
- if (noRemote || typeof fetchImpl !== 'function') return { checks: bundled.checks, source: 'bundled' };
756
- const cachePath = join(home, '.cache', 'dsh-doctor', 'checks.json');
757
- const readCache = () => { if (!existsSync(cachePath)) return null; try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return validCatalog(d) ? d : null; } catch { return null; } };
758
- try {
759
- const cached = readCache();
760
- if (cached && Date.now() - statSync(cachePath).mtimeMs < CATALOG_TTL_MS) return { checks: cached.checks, source: 'cache' };
761
- } catch { /* 回退 */ }
762
- try {
763
- const ac = new AbortController();
764
- const timer = setTimeout(() => ac.abort(), 3000);
765
- const res = await fetchImpl(REMOTE_CATALOG_URL, { signal: ac.signal });
766
- clearTimeout(timer);
767
- if (res && res.ok) {
768
- const data = await res.json();
769
- if (validCatalog(data)) {
770
- try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(data, null, 2)); } catch { /* 缓存写入失败不影响本次运行 */ }
771
- return { checks: data.checks, source: 'remote' };
772
- }
787
+ let base;
788
+ if (noRemote || typeof fetchImpl !== 'function') {
789
+ base = { checks: bundled.checks, source: 'bundled' };
790
+ } else {
791
+ const cachePath = join(home, '.cache', 'dsh-doctor', 'checks.json');
792
+ const readCache = () => { if (!existsSync(cachePath)) return null; try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return validCatalog(d) ? d : null; } catch { return null; } };
793
+ try {
794
+ const cached = readCache();
795
+ if (cached && Date.now() - statSync(cachePath).mtimeMs < CATALOG_TTL_MS) base = { checks: cached.checks, source: 'cache' };
796
+ } catch { /* 回退 */ }
797
+ if (!base) {
798
+ try {
799
+ const ac = new AbortController();
800
+ const timer = setTimeout(() => ac.abort(), 3000);
801
+ const res = await fetchImpl(REMOTE_CATALOG_URL, { signal: ac.signal });
802
+ clearTimeout(timer);
803
+ if (res && res.ok) {
804
+ const data = await res.json();
805
+ if (validCatalog(data)) {
806
+ try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(data, null, 2)); } catch { /* 缓存写入失败不影响本次运行 */ }
807
+ base = { checks: data.checks, source: 'remote' };
808
+ }
809
+ }
810
+ } catch { /* 离线/超时 → 回退 */ }
773
811
  }
774
- } catch { /* 离线/超时 → 回退 */ }
775
- const stale = readCache();
776
- if (stale) return { checks: stale.checks, source: 'cache-stale' };
777
- return { checks: bundled.checks, source: 'bundled' };
812
+ if (!base) {
813
+ const stale = readCache();
814
+ base = stale ? { checks: stale.checks, source: 'cache-stale' } : { checks: bundled.checks, source: 'bundled' };
815
+ }
816
+ }
817
+ const local = localOverlay(localPath);
818
+ if (!local.length) return base;
819
+ return { checks: [...base.checks, ...local], source: base.source === 'bundled' ? 'bundled+local' : `${base.source}+local` };
778
820
  }
779
821
 
780
822
  function expandPath(tpl, ctx) {
@@ -1004,10 +1046,43 @@ export function runUpdate() {
1004
1046
  }
1005
1047
 
1006
1048
  /* ================= main ================= */
1049
+ const flagValue = (name) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : undefined; };
1007
1050
  const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
1008
1051
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
1009
1052
 
1010
1053
  async function run() {
1054
+ // 层 C 观察者(--observe / --observe-apply):独立子命令,跑完即退出,不执行常规检查
1055
+ const observeArg = flagValue('--observe');
1056
+ const observeApplyArg = flagValue('--observe-apply');
1057
+ const llmCmd = process.argv.includes('--observe-llm') ? flagValue('--observe-llm') : process.env.DSH_DOCTOR_LLM_CMD ?? null;
1058
+ if (observeArg || observeApplyArg) {
1059
+ const { runObserver, applyProposals, writeLocalOverlay, readLocalOverlay } = await import('./observer.mjs');
1060
+ try {
1061
+ if (observeApplyArg) {
1062
+ const raw = JSON.parse(readFileSync(resolve(observeApplyArg), 'utf8'));
1063
+ const list = Array.isArray(raw) ? raw : raw.proposals ?? [];
1064
+ const overlayPath = join(dirname(fileURLToPath(import.meta.url)), 'checks.local.json');
1065
+ // 覆盖层只追加新提案(loadCatalog 会 base + local 合并),且对已存在覆盖层幂等
1066
+ const existing = readLocalOverlay(overlayPath);
1067
+ const { catalog: merged, applied, rejected } = applyProposals({ schemaVersion: 1, checks: existing }, list);
1068
+ if (applied.length) {
1069
+ writeLocalOverlay(overlayPath, {
1070
+ schemaVersion: 1,
1071
+ description: 'Layer C 观察者本地覆盖层——未认证提案,不随包分发;认证通过后请合并进 checks.json 并删除本文件。',
1072
+ checks: merged.checks,
1073
+ });
1074
+ }
1075
+ console.log(JSON.stringify({ ok: true, written: applied.length ? overlayPath : null, applied: applied.map((p) => p.id), rejected }, null, 2));
1076
+ process.exit(0);
1077
+ }
1078
+ const res = await runObserver({ path: observeArg, existingChecks: bundledCatalog().checks, llmCmd });
1079
+ console.log(JSON.stringify(res, null, 2));
1080
+ process.exit(0);
1081
+ } catch (e) {
1082
+ console.error(`观察者失败: ${e.message}`);
1083
+ process.exit(1);
1084
+ }
1085
+ }
1011
1086
  try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
1012
1087
  try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
1013
1088
  try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
@@ -1043,7 +1118,7 @@ async function run() {
1043
1118
  if (jsonOut && process.argv.includes('--envelope')) {
1044
1119
  // v1 契约信封(dsh doctor 规格,zoahdev/doctor 对齐):status 小写 + 退出码 0/1/2
1045
1120
  const st = (r) => (!r.ok ? (catalogSeverity.get(r.id) === 'warn' ? 'warn' : 'fail') : 'pass');
1046
- const summary = { pass: 0, warn: 0, fail: 0 };
1121
+ const summary = { pass: 0, warn: 0, fail: 0, skip: 0 }; // skip 常驻(v1 词汇表 r5:#1719),当前无平台作用域检查所以恒为 0
1047
1122
  const checks = results.map((r) => { summary[st(r)]++; return { name: r.id, status: st(r), detail: r.detail }; });
1048
1123
  const exitCode = summary.fail > 0 ? 2 : summary.warn > 0 ? 1 : 0;
1049
1124
  console.log(JSON.stringify({
@@ -1083,4 +1158,4 @@ async function run() {
1083
1158
  // 直接执行(CLI:根目录薄封装、plugin 本体、npm bin 均可);被 import(测试/宿主)时不自动运行
1084
1159
  if (process.argv[1] && /^dsh-doctor(\.mjs)?$/.test(basename(process.argv[1]))) run();
1085
1160
 
1086
- export { loadCatalog, bundledCatalog, validCatalog, expandPath, globCount, checkCatalog };
1161
+ export { loadCatalog, bundledCatalog, validCatalog, expandPath, globCount, checkCatalog, nodeInSupportedRange };
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.7",
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.1",
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
+ }