@moonquake2004/dsh-doctor 0.2.7 → 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
@@ -126,6 +126,16 @@ The built-in 20 checks are compiled into the tool. The **catalog** is a second,
126
126
 
127
127
  Catalog check results are marked `src: "catalog"` in JSON output and `[目录]` in CLI output.
128
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
+
129
139
  ## Also installable as a dsh plugin
130
140
 
131
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
@@ -119,6 +119,16 @@ The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form
119
119
 
120
120
  目录检查的结果在 JSON 输出中标 `src: "catalog"`,CLI 输出标 `[目录]`。
121
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
+
122
132
  ## 也可作为 dsh 插件安装
123
133
 
124
134
  工具以标准 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
 
@@ -217,7 +217,17 @@ function portOccupierInfo(port) {
217
217
  const pid = parts[1] || '';
218
218
  let dsh = /dsh|deepseek/.test(cmd);
219
219
  if (!dsh && pid) {
220
- 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
+ }
221
231
  }
222
232
  return { pid, cmd, dsh };
223
233
  } catch { return null; }
@@ -745,36 +755,52 @@ function bundledCatalog() {
745
755
  try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { schemaVersion: 1, checks: [] }; }
746
756
  }
747
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
+
748
764
  function validCatalog(data) {
749
765
  return !!data && data.schemaVersion === 1 && Array.isArray(data.checks);
750
766
  }
751
767
 
752
- /** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本。 */
753
- 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 } = {}) {
754
770
  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
- }
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 { /* 离线/超时 → 回退 */ }
773
795
  }
774
- } catch { /* 离线/超时 → 回退 */ }
775
- const stale = readCache();
776
- if (stale) return { checks: stale.checks, source: 'cache-stale' };
777
- 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` };
778
804
  }
779
805
 
780
806
  function expandPath(tpl, ctx) {
@@ -1004,10 +1030,43 @@ export function runUpdate() {
1004
1030
  }
1005
1031
 
1006
1032
  /* ================= main ================= */
1033
+ const flagValue = (name) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : undefined; };
1007
1034
  const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
1008
1035
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
1009
1036
 
1010
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
+ }
1011
1070
  try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
1012
1071
  try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
1013
1072
  try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
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.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
+ }