oh-my-knowledge 0.45.0 → 0.46.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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/assets/agent-skills/omk/SKILL.md +3 -3
  4. package/dist/assets/agent-skills/omk/references/commands.md +13 -6
  5. package/dist/cli/commands/doctor.d.ts +3 -2
  6. package/dist/cli/commands/doctor.js +66 -63
  7. package/dist/cli/lib/cmd-flags.d.ts +3 -2
  8. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  9. package/dist/cli/lib/i18n-dict/common.js +0 -4
  10. package/dist/doctor/endpoint-rule.js +1 -1
  11. package/dist/doctor/health/composer.js +162 -50
  12. package/dist/doctor/health/consensus.d.ts +43 -0
  13. package/dist/doctor/health/consensus.js +224 -0
  14. package/dist/doctor/health/dimension-spec.d.ts +6 -0
  15. package/dist/doctor/health/register.d.ts +0 -1
  16. package/dist/doctor/health/register.js +0 -1
  17. package/dist/doctor/index.js +3 -0
  18. package/dist/doctor/messages.d.ts +1 -1
  19. package/dist/doctor/messages.js +8 -0
  20. package/dist/doctor/rules.d.ts +4 -2
  21. package/dist/doctor/rules.js +4 -2
  22. package/dist/executors/claude-cli.js +1 -1
  23. package/dist/observability/skill-chain-advisories.js +2 -2
  24. package/dist/renderer/doctor-detail-renderer.js +30 -2
  25. package/dist/renderer/report-shell.d.ts +1 -1
  26. package/dist/renderer/report-shell.js +5 -0
  27. package/dist/renderer/skill-detail-renderer.js +844 -100
  28. package/dist/server/skill-index.js +37 -1
  29. package/dist/shared/llm-prompts/registry.js +2 -1
  30. package/dist/shared/llm-prompts/skill-health-merge.d.ts +22 -0
  31. package/dist/shared/llm-prompts/skill-health-merge.js +79 -0
  32. package/dist/shared/llm-prompts/skill-health.js +1 -1
  33. package/dist/types/doctor.d.ts +32 -8
  34. package/dist/types/skill-index.d.ts +10 -0
  35. package/package.json +4 -3
@@ -18,6 +18,8 @@ import { createExecutor } from '../../executors/index.js';
18
18
  import { getRegisteredHealthDimensions } from './dimension-registry.js';
19
19
  import { buildHealthPrompt } from './prompt-builder.js';
20
20
  import { deriveOverallHealth, extractJson, parseHealthOutput } from './parser.js';
21
+ import { enumerateFindingsForMerge, mergeHealthSamples, mergeHealthSamplesLlm, parseMergeClusters, } from './consensus.js';
22
+ import { buildHealthMergePrompt } from '../../shared/llm-prompts/skill-health-merge.js';
21
23
  export const SKILL_HEALTH_COMPOSER_ID = 'skill_health';
22
24
  const defaultExecutorFactory = createExecutor;
23
25
  // ---------------------------------------------------------------------------
@@ -125,6 +127,24 @@ function listSiblingSkills(skillRoot) {
125
127
  .sort();
126
128
  }
127
129
  // ---------------------------------------------------------------------------
130
+ // 有界并发 map
131
+ // ---------------------------------------------------------------------------
132
+ /** 最多 limit 个任务并发,返回与 items 同序的结果(worker-pool,对齐 eval-core 口径)。 */
133
+ async function mapWithConcurrency(items, limit, fn) {
134
+ const results = new Array(items.length);
135
+ let next = 0;
136
+ const worker = async () => {
137
+ while (next < items.length) {
138
+ const i = next;
139
+ next += 1;
140
+ results[i] = await fn(items[i], i);
141
+ }
142
+ };
143
+ const workers = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 1, items.length));
144
+ await Promise.all(Array.from({ length: workers }, () => worker()));
145
+ return results;
146
+ }
147
+ // ---------------------------------------------------------------------------
128
148
  // Composer factory
129
149
  // ---------------------------------------------------------------------------
130
150
  export function makeSkillHealthComposer(execFactory = defaultExecutorFactory) {
@@ -139,7 +159,7 @@ export function makeSkillHealthComposer(execFactory = defaultExecutorFactory) {
139
159
  async function composerCheckAll(ctx, execFactory) {
140
160
  const dims = getRegisteredHealthDimensions();
141
161
  // Programmatic opt-out: runHealthCheck=false → 给 summary 一条 skipped,
142
- // 各维度不展开避免污染报告。CLI --static-only 会直接过滤掉 composer rule
162
+ // 各维度不展开避免污染报告。eval preflight 走这条(只跑静态 rule,不触发 LLM)
143
163
  if (!ctx.runHealthCheck) {
144
164
  return [{
145
165
  subId: '_summary',
@@ -176,44 +196,124 @@ async function composerCheckAll(ctx, execFactory) {
176
196
  const siblingSkills = listSiblingSkills(skillRoot);
177
197
  const prompt = buildHealthPrompt({ skillName, skillContent: content, skillRoot, subFiles, siblingSkills }, dims);
178
198
  const executor = execFactory(ctx.executorName);
179
- let res;
180
- try {
181
- res = await executor({
182
- model: ctx.model,
183
- prompt,
184
- cwd: skillRoot ?? ctx.cwd,
185
- skillDir: skillRoot ?? null,
186
- timeoutMs: ctx.timeoutMs,
187
- effort: ctx.effort ?? 'medium',
188
- lean: true,
189
- });
190
- }
191
- catch (err) {
192
- const msg = err instanceof Error ? err.message : String(err);
193
- return [errorSummaryOutcome(ctx, 'executor', msg)];
194
- }
195
- if (!res.ok) {
196
- return [errorSummaryOutcome(ctx, 'executor', res.error ?? 'unknown')];
197
- }
198
- if (!res.output || res.output.trim().length === 0) {
199
- return [errorSummaryOutcome(ctx, 'empty_output', '')];
199
+ // 采样次数(self-consistency):默认 1;非有限值(NaN/Infinity)兜底 1;上限 10 防 fat-finger
200
+ // / 资源耗尽(并发默认=采样数,会一起放大)。
201
+ const MAX_HEALTH_SAMPLES = 10;
202
+ const rawSamples = ctx.healthSamples;
203
+ const requested = Math.min(MAX_HEALTH_SAMPLES, Math.max(1, Math.floor(Number.isFinite(rawSamples) ? rawSamples : 1)));
204
+ // 跨采样累计的执行指标(token / cost / 时长 / 轮次都求和,反映真实总开销)。
205
+ const agg = {
206
+ durationMs: 0, durationApiMs: 0,
207
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
208
+ costUSD: 0, numTurns: 0, fullNumTurns: 0,
209
+ };
210
+ const accumulate = (r) => {
211
+ agg.durationMs += r.durationMs ?? 0;
212
+ agg.durationApiMs += r.durationApiMs ?? 0;
213
+ agg.inputTokens += r.inputTokens ?? 0;
214
+ agg.outputTokens += r.outputTokens ?? 0;
215
+ agg.cacheReadTokens += r.cacheReadTokens ?? 0;
216
+ agg.cacheCreationTokens += r.cacheCreationTokens ?? 0;
217
+ agg.costUSD += r.costUSD ?? 0;
218
+ agg.numTurns += r.numTurns ?? 0;
219
+ agg.fullNumTurns += r.fullNumTurns ?? 0;
220
+ };
221
+ // 采样并发:默认全并行(并发=采样数,样本间相互独立),`--concurrency 1` 退回串行。
222
+ // 非有限值兜底为 requested。
223
+ const rawConc = ctx.healthConcurrency;
224
+ const concurrency = Math.max(1, Math.min(requested, Math.floor(Number.isFinite(rawConc) ? rawConc : requested)));
225
+ const runSample = async () => {
226
+ let res;
227
+ try {
228
+ res = await executor({
229
+ model: ctx.model,
230
+ prompt,
231
+ cwd: skillRoot ?? ctx.cwd,
232
+ skillDir: skillRoot ?? null,
233
+ timeoutMs: ctx.timeoutMs,
234
+ effort: ctx.effort ?? 'low',
235
+ lean: true,
236
+ });
237
+ }
238
+ catch (err) {
239
+ return { error: { phase: 'executor', msg: err instanceof Error ? err.message : String(err) } };
240
+ }
241
+ if (!res.ok)
242
+ return { res, error: { phase: 'executor', msg: res.error ?? 'unknown' } };
243
+ if (!res.output || res.output.trim().length === 0)
244
+ return { res, error: { phase: 'empty_output', msg: '' } };
245
+ const cleaned = extractJson(res.output);
246
+ if (cleaned === null)
247
+ return { res, error: { phase: 'extract', msg: '', extra: { rawOutput: res.output.slice(0, 4000) } } };
248
+ const pr = parseHealthOutput(cleaned, dims);
249
+ // 全维度 _missing → 该样本的 JSON 里 dim_id 缺失或写错,丢弃(不当作"全部通过")。
250
+ if ([...pr.byDimId.values()].every((r) => r._missing)) {
251
+ return { res, error: { phase: 'extract', msg: 'LLM output missing all dimension IDs', extra: { rawSample: cleaned.slice(0, 3000) } } };
252
+ }
253
+ return { res, parsed: pr };
254
+ };
255
+ // 跑 N 次(并发受 concurrency 限),收集成功解析的样本。单样本报错 / 空输出 / JSON 抽不出 /
256
+ // 全维度漏报都跳过(记最后一次错误);只要 ≥1 成功就走并集合并,全失败才报 fail。
257
+ const sampleResults = await mapWithConcurrency(Array.from({ length: requested }, (_, i) => i), concurrency, () => runSample());
258
+ const parsed = [];
259
+ let lastError = null;
260
+ for (const sr of sampleResults) {
261
+ if (sr.res)
262
+ accumulate(sr.res);
263
+ if (sr.parsed)
264
+ parsed.push(sr.parsed);
265
+ else if (sr.error)
266
+ lastError = sr.error;
200
267
  }
201
- const cleaned = extractJson(res.output);
202
- if (cleaned === null) {
203
- return [errorSummaryOutcome(ctx, 'extract', '', { rawOutput: res.output.slice(0, 4000) })];
268
+ if (parsed.length === 0) {
269
+ return [errorSummaryOutcome(ctx, lastError?.phase ?? 'extract', lastError?.msg ?? '', lastError?.extra ?? {})];
204
270
  }
205
- const parseResult = parseHealthOutput(cleaned, dims);
206
- const overall = parseResult.overall ?? deriveOverallHealth(parseResult.byDimId);
207
- // 诊断:如果所有维度都被标 _missing,说明 LLM 输出 JSON 里 dim_id 字段缺失或写错。
208
- // 此时应报 fail(解析失败),不能当作"全部通过"。
209
- const allMissing = [...parseResult.byDimId.values()].every((r) => r._missing);
210
- if (allMissing) {
211
- return [errorSummaryOutcome(ctx, 'extract', 'LLM output missing all dimension IDs', { rawSample: cleaned.slice(0, 3000) })];
271
+ // 归并:healthMerge='string' 走字符串键;='llm'(CLI 默认)且确有可并 finding 时,多跑一次
272
+ // LLM 聚类(跨措辞同根因归并),失败回退 string。单样本时两者都等价于直通 + support={1,1}。
273
+ const mergeStrategy = ctx.healthMerge ?? 'string';
274
+ let merged = null;
275
+ let mergeMode = 'string';
276
+ if (mergeStrategy === 'llm' && parsed.length > 1) {
277
+ const tagged = enumerateFindingsForMerge(parsed, dims);
278
+ // 仅当某维度内 ≥2 条 finding 才值得跑 LLM 聚类(否则没东西可并,白花一次调用)。
279
+ const mergeable = dims.some((d) => tagged.filter((t) => t.dimId === d.id).length >= 2);
280
+ if (mergeable) {
281
+ let mres = null;
282
+ try {
283
+ mres = await executor({
284
+ model: ctx.model,
285
+ prompt: buildHealthMergePrompt(dims, tagged),
286
+ cwd: skillRoot ?? ctx.cwd,
287
+ skillDir: skillRoot ?? null,
288
+ timeoutMs: ctx.timeoutMs,
289
+ effort: ctx.effort ?? 'low',
290
+ lean: true,
291
+ });
292
+ }
293
+ catch {
294
+ mres = null;
295
+ }
296
+ if (mres)
297
+ accumulate(mres);
298
+ const cleaned = mres?.ok && mres.output ? extractJson(mres.output) : null;
299
+ const clusters = cleaned ? parseMergeClusters(cleaned) : null;
300
+ if (clusters) {
301
+ merged = mergeHealthSamplesLlm(parsed, dims, tagged, clusters);
302
+ mergeMode = 'llm';
303
+ }
304
+ else {
305
+ mergeMode = 'string(llm-fallback)'; // LLM 合并失败 → 用字符串键兜底
306
+ }
307
+ }
212
308
  }
309
+ if (!merged)
310
+ merged = mergeHealthSamples(parsed, dims);
311
+ const overall = merged.overall ?? deriveOverallHealth(merged.byDimId);
312
+ const succeeded = parsed.length;
213
313
  // 汇总 finding 计数(框架算,不信 LLM 算术)
214
314
  const finding = { '错误': 0, '警告': 0, '建议': 0 };
215
315
  const dimCount = { '健康': 0, '亚健康': 0, '不健康': 0, '不适用': 0 };
216
- for (const r of parseResult.byDimId.values()) {
316
+ for (const r of merged.byDimId.values()) {
217
317
  if (r._missing)
218
318
  continue;
219
319
  dimCount[r.level] += 1;
@@ -222,7 +322,7 @@ async function composerCheckAll(ctx, execFactory) {
222
322
  }
223
323
  // N 条维度 outcome
224
324
  const dimOutcomes = dims.map((dim) => {
225
- const r = parseResult.byDimId.get(dim.id);
325
+ const r = merged.byDimId.get(dim.id);
226
326
  return {
227
327
  subId: dim.id,
228
328
  labelKey: dim.labelKey,
@@ -240,35 +340,47 @@ async function composerCheckAll(ctx, execFactory) {
240
340
  },
241
341
  };
242
342
  });
343
+ // summary message:多采样时(requested>1)追加采样次数说明,让终端看到"并集来自几次采样"。
344
+ let summaryMessage = tDoctorMessage('cli.doctor.health.summary.message', ctx.lang, {
345
+ overall,
346
+ h: dimCount['健康'], sh: dimCount['亚健康'], bad: dimCount['不健康'], na: dimCount['不适用'],
347
+ err: finding['错误'], warn: finding['警告'], sug: finding['建议'],
348
+ });
349
+ if (requested > 1) {
350
+ summaryMessage += tDoctorMessage(succeeded < requested
351
+ ? 'cli.doctor.health.summary.samples_degraded'
352
+ : 'cli.doctor.health.summary.samples', ctx.lang, { requested, succeeded });
353
+ }
354
+ const sampleDegraded = requested > 1 && succeeded < requested;
243
355
  // 1 条 summary outcome
244
356
  const summaryOutcome = {
245
357
  subId: '_summary',
246
358
  severity: 'info',
247
359
  labelKey: 'cli.doctor.health.summary.label',
248
- status: 'pass', // summary 永远 pass(信息性,不影响 outcome)
249
- message: tDoctorMessage('cli.doctor.health.summary.message', ctx.lang, {
250
- overall,
251
- h: dimCount['健康'], sh: dimCount['亚健康'], bad: dimCount['不健康'], na: dimCount['不适用'],
252
- err: finding['错误'], warn: finding['警告'], sug: finding['建议'],
253
- }),
254
- hint: parseResult.topSuggestions.slice(0, 3).join(' | ')
360
+ status: sampleDegraded ? 'warn' : 'pass',
361
+ message: summaryMessage,
362
+ hint: merged.topSuggestions.slice(0, 3).join(' | ')
255
363
  || tDoctorMessage('cli.doctor.health.summary.no_top', ctx.lang),
256
364
  detail: {
257
365
  overall,
366
+ // 采样次数:requested=请求几次,succeeded=成功解析几次。finding 的 support=k/n
367
+ // 即"被评估的 n 次里有 k 次报了这条"。mergeMode=归并实现(string / llm / 回退)。
368
+ samples: { requested, succeeded, concurrency, degraded: sampleDegraded },
369
+ mergeMode,
258
370
  dimensionLevelCount: dimCount,
259
371
  findingCountByLevel: finding,
260
- topSuggestions: parseResult.topSuggestions,
261
- featurePoints: parseResult.featurePoints,
262
- durationMs: res.durationMs,
263
- durationApiMs: res.durationApiMs,
264
- // turns:claude-cli 在内部跑的 LLM 轮次(每轮 = 1 次 messages.create)
372
+ topSuggestions: merged.topSuggestions,
373
+ featurePoints: merged.featurePoints,
374
+ durationMs: agg.durationMs,
375
+ durationApiMs: agg.durationApiMs,
376
+ // turns:claude-cli 在内部跑的 LLM 轮次(每轮 = 1 次 messages.create),多采样累加。
265
377
  // numTurns:assistant turn 数 / fullNumTurns:含 tool turn 的总数
266
- llmTurns: { numTurns: res.numTurns, fullNumTurns: res.fullNumTurns },
378
+ llmTurns: { numTurns: agg.numTurns, fullNumTurns: agg.fullNumTurns },
267
379
  tokens: {
268
- input: res.inputTokens, output: res.outputTokens,
269
- cacheRead: res.cacheReadTokens, cacheCreation: res.cacheCreationTokens,
380
+ input: agg.inputTokens, output: agg.outputTokens,
381
+ cacheRead: agg.cacheReadTokens, cacheCreation: agg.cacheCreationTokens,
270
382
  },
271
- costUSD: res.costUSD,
383
+ costUSD: agg.costUSD,
272
384
  executor: { name: ctx.executorName, model: ctx.model },
273
385
  },
274
386
  };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 多采样共识合并 —— self-consistency 抹平单次采样方差。
3
+ *
4
+ * composer 串行跑 N 次 LLM,各次产出独立的 HealthParseResult。本模块把这 N 份
5
+ * 结果**按维度取并集**:同根因 finding 跨采样合并成一条,标注支持度 `k/n`
6
+ * (该维度被评估的 n 次里,有 k 次报了这条)。**默认不过滤** —— 召回优先,
7
+ * 低支持度的也保留并如实标注,让重复体检看到的是"并集",而不是每次抖动的子集。
8
+ *
9
+ * 两种归并实现,共用 assembleDimResult 出维度结果:
10
+ * - mergeHealthSamples(string,默认):按 findingKey(反引号锚点 / 归一化描述前缀)
11
+ * 聚类。便宜、无额外 LLM 调用;缺点是同根因不同措辞可能漏并(拆成多条低支持度)。
12
+ * - mergeHealthSamplesLlm(option C):用一次 LLM 聚类(skill-health-merge prompt)的
13
+ * 结果分组,跨措辞归并最准;composer 在 healthMerge='llm' 时调用,失败回退 string。
14
+ */
15
+ import type { HealthDimensionSpec, HealthFinding, HealthFindingLevel } from './dimension-spec.js';
16
+ import type { HealthParseResult } from './parser.js';
17
+ /**
18
+ * 同根因合并键。锚点优先(跨采样最稳),退回归一化描述前缀。
19
+ * 不含 level:同一问题不同采样 level 可能不同,合并时单独取最严重。
20
+ */
21
+ export declare function findingKey(f: HealthFinding): string;
22
+ export declare function mergeHealthSamples(samples: HealthParseResult[], dims: HealthDimensionSpec[]): HealthParseResult;
23
+ /** 带稳定 id 的采样 finding(喂给 merge prompt + 回填分组)。 */
24
+ export interface TaggedFinding {
25
+ id: string;
26
+ sampleIdx: number;
27
+ dimId: string;
28
+ finding: HealthFinding;
29
+ }
30
+ /** 枚举所有采样里所有维度的 finding,赋稳定 id(f0, f1, …),供 LLM 聚类引用。 */
31
+ export declare function enumerateFindingsForMerge(samples: HealthParseResult[], dims: HealthDimensionSpec[]): TaggedFinding[];
32
+ /** merge LLM 输出的一个聚类。 */
33
+ export interface MergeCluster {
34
+ dimId: string;
35
+ findingIds: string[];
36
+ level?: HealthFindingLevel;
37
+ description?: string;
38
+ suggestion?: string;
39
+ }
40
+ /** 解析 merge LLM 的 clusters JSON(已 extractJson)。非法返回 null → composer 回退 string。 */
41
+ export declare function parseMergeClusters(jsonText: string): MergeCluster[] | null;
42
+ /** 按 LLM 聚类结果合并。未被任何 cluster 覆盖的 finding 兜底自成一组(绝不丢)。 */
43
+ export declare function mergeHealthSamplesLlm(samples: HealthParseResult[], dims: HealthDimensionSpec[], tagged: TaggedFinding[], clusters: MergeCluster[]): HealthParseResult;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * 多采样共识合并 —— self-consistency 抹平单次采样方差。
3
+ *
4
+ * composer 串行跑 N 次 LLM,各次产出独立的 HealthParseResult。本模块把这 N 份
5
+ * 结果**按维度取并集**:同根因 finding 跨采样合并成一条,标注支持度 `k/n`
6
+ * (该维度被评估的 n 次里,有 k 次报了这条)。**默认不过滤** —— 召回优先,
7
+ * 低支持度的也保留并如实标注,让重复体检看到的是"并集",而不是每次抖动的子集。
8
+ *
9
+ * 两种归并实现,共用 assembleDimResult 出维度结果:
10
+ * - mergeHealthSamples(string,默认):按 findingKey(反引号锚点 / 归一化描述前缀)
11
+ * 聚类。便宜、无额外 LLM 调用;缺点是同根因不同措辞可能漏并(拆成多条低支持度)。
12
+ * - mergeHealthSamplesLlm(option C):用一次 LLM 聚类(skill-health-merge prompt)的
13
+ * 结果分组,跨措辞归并最准;composer 在 healthMerge='llm' 时调用,失败回退 string。
14
+ */
15
+ import { deriveOverallHealth } from './parser.js';
16
+ const FINDING_SEVERITY = { 错误: 3, 警告: 2, 建议: 1 };
17
+ function isFindingLevel(s) {
18
+ return s === '错误' || s === '警告' || s === '建议';
19
+ }
20
+ function deriveDimLevel(findings) {
21
+ if (findings.some((f) => f.level === '错误'))
22
+ return '不健康';
23
+ if (findings.length > 0)
24
+ return '亚健康';
25
+ return '健康';
26
+ }
27
+ /**
28
+ * 同根因合并键。锚点优先(跨采样最稳),退回归一化描述前缀。
29
+ * 不含 level:同一问题不同采样 level 可能不同,合并时单独取最严重。
30
+ */
31
+ export function findingKey(f) {
32
+ const text = `${f.description ?? ''} ${f.suggestion ?? ''}`;
33
+ const anchors = [...text.matchAll(/`([^`]+)`/g)]
34
+ .map((m) => m[1].trim().toLowerCase())
35
+ .filter((s) => s.length > 0);
36
+ if (anchors.length > 0) {
37
+ return `a:${[...new Set(anchors)].sort().join(',')}`;
38
+ }
39
+ // 去掉空白 / 标点 / 符号后取前缀(中文字符保留)
40
+ const norm = (f.description ?? '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
41
+ return `d:${norm.slice(0, 48)}`;
42
+ }
43
+ /** 由分组好的 FindingGroup 组装一个维度结果(support、排序、维度 level 派生)。 */
44
+ function assembleDimResult(presentLevels, n, groups, suggestions) {
45
+ const findings = groups.map((g) => {
46
+ // representative 取描述更详尽的一条;level 取组内最严重(除非 LLM 显式给了)。
47
+ const rep = [...g.members].sort((a, b) => (b.description?.length ?? 0) - (a.description?.length ?? 0))[0];
48
+ const worst = g.members.reduce((acc, f) => (FINDING_SEVERITY[f.level] > FINDING_SEVERITY[acc] ? f.level : acc), '建议');
49
+ return {
50
+ ...rep,
51
+ // override.level 只能"升"不能"降":worst(组内最严重)是下限,LLM 聚类觉得更严重才采纳。
52
+ // 否则一次归并能把真 错误 降成 警告(维度 不健康→亚健康),把真问题藏掉。
53
+ level: g.override?.level && FINDING_SEVERITY[g.override.level] > FINDING_SEVERITY[worst]
54
+ ? g.override.level
55
+ : worst,
56
+ description: g.override?.description ?? rep.description,
57
+ suggestion: g.override?.suggestion ?? rep.suggestion,
58
+ support: { k: g.sampleIdxs.size, n },
59
+ };
60
+ });
61
+ // 排序:支持度高在前,其次错误优先 —— 最可信、最严重的排最前。
62
+ findings.sort((x, y) => {
63
+ const sk = (y.support?.k ?? 0) - (x.support?.k ?? 0);
64
+ if (sk !== 0)
65
+ return sk;
66
+ return FINDING_SEVERITY[y.level] - FINDING_SEVERITY[x.level];
67
+ });
68
+ // 零 finding 时:只有所有采样都判不适用才算不适用(任一判健康 = 适用且健康)。
69
+ const level = findings.length > 0
70
+ ? deriveDimLevel(findings)
71
+ : presentLevels.every((l) => l === '不适用') ? '不适用' : '健康';
72
+ return { level, findings, suggestions };
73
+ }
74
+ /** 该维度被"实际评估"过的采样(present 且非 _missing;健康=0 finding 也算评估过)。 */
75
+ function presentSamplesFor(samples, dimId) {
76
+ const out = [];
77
+ samples.forEach((s, idx) => {
78
+ const r = s.byDimId.get(dimId);
79
+ if (r != null && !r._missing)
80
+ out.push({ idx, r });
81
+ });
82
+ return out;
83
+ }
84
+ function finalize(byDimId, samples) {
85
+ const topSet = new Set();
86
+ for (const s of samples)
87
+ for (const t of s.topSuggestions)
88
+ topSet.add(t);
89
+ const featurePoints = samples.find((s) => s.featurePoints.length > 0)?.featurePoints ?? [];
90
+ return { byDimId, overall: deriveOverallHealth(byDimId), topSuggestions: [...topSet], featurePoints };
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // string 归并(默认,findingKey 聚类)
94
+ // ---------------------------------------------------------------------------
95
+ export function mergeHealthSamples(samples, dims) {
96
+ const byDimId = new Map();
97
+ for (const dim of dims) {
98
+ const present = presentSamplesFor(samples, dim.id);
99
+ if (present.length === 0) {
100
+ byDimId.set(dim.id, { level: '不适用', findings: [], suggestions: [], _missing: true });
101
+ continue;
102
+ }
103
+ const suggestionsSet = new Set();
104
+ const groupMap = new Map();
105
+ for (const { idx, r } of present) {
106
+ for (const sug of r.suggestions)
107
+ suggestionsSet.add(sug);
108
+ for (const f of r.findings) {
109
+ const key = findingKey(f);
110
+ let g = groupMap.get(key);
111
+ if (!g) {
112
+ g = { members: [], sampleIdxs: new Set() };
113
+ groupMap.set(key, g);
114
+ }
115
+ g.members.push(f);
116
+ g.sampleIdxs.add(idx);
117
+ }
118
+ }
119
+ // n(支持度分母)= 已评估且适用的采样数;不适用采样不算"评估过这条 finding"。
120
+ const assessedN = present.filter((p) => p.r.level !== '不适用').length;
121
+ byDimId.set(dim.id, assembleDimResult(present.map((p) => p.r.level), assessedN, [...groupMap.values()], [...suggestionsSet]));
122
+ }
123
+ return finalize(byDimId, samples);
124
+ }
125
+ /** 枚举所有采样里所有维度的 finding,赋稳定 id(f0, f1, …),供 LLM 聚类引用。 */
126
+ export function enumerateFindingsForMerge(samples, dims) {
127
+ const out = [];
128
+ let counter = 0;
129
+ samples.forEach((s, sampleIdx) => {
130
+ for (const dim of dims) {
131
+ const r = s.byDimId.get(dim.id);
132
+ if (!r || r._missing)
133
+ continue;
134
+ for (const f of r.findings) {
135
+ out.push({ id: `f${counter}`, sampleIdx, dimId: dim.id, finding: f });
136
+ counter += 1;
137
+ }
138
+ }
139
+ });
140
+ return out;
141
+ }
142
+ /** 解析 merge LLM 的 clusters JSON(已 extractJson)。非法返回 null → composer 回退 string。 */
143
+ export function parseMergeClusters(jsonText) {
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(jsonText);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ if (!parsed || typeof parsed !== 'object')
152
+ return null;
153
+ const raw = parsed.clusters;
154
+ if (!Array.isArray(raw))
155
+ return null;
156
+ const out = [];
157
+ for (const item of raw) {
158
+ if (!item || typeof item !== 'object')
159
+ continue;
160
+ const c = item;
161
+ if (typeof c.dim_id !== 'string')
162
+ continue;
163
+ if (!Array.isArray(c.finding_ids))
164
+ continue;
165
+ const ids = c.finding_ids.filter((x) => typeof x === 'string');
166
+ if (ids.length === 0)
167
+ continue;
168
+ out.push({
169
+ dimId: c.dim_id,
170
+ findingIds: ids,
171
+ level: isFindingLevel(c.level) ? c.level : undefined,
172
+ description: typeof c.description === 'string' && c.description.trim() ? c.description : undefined,
173
+ suggestion: typeof c.suggestion === 'string' && c.suggestion.trim() ? c.suggestion : undefined,
174
+ });
175
+ }
176
+ return out.length > 0 ? out : null;
177
+ }
178
+ /** 按 LLM 聚类结果合并。未被任何 cluster 覆盖的 finding 兜底自成一组(绝不丢)。 */
179
+ export function mergeHealthSamplesLlm(samples, dims, tagged, clusters) {
180
+ const byId = new Map(tagged.map((t) => [t.id, t]));
181
+ const covered = new Set();
182
+ const groupsByDim = new Map();
183
+ const addGroup = (dimId, g) => {
184
+ if (!groupsByDim.has(dimId))
185
+ groupsByDim.set(dimId, []);
186
+ groupsByDim.get(dimId).push(g);
187
+ };
188
+ for (const c of clusters) {
189
+ const members = c.findingIds
190
+ .map((id) => byId.get(id))
191
+ .filter((t) => t != null && t.dimId === c.dimId && !covered.has(t.id));
192
+ if (members.length === 0)
193
+ continue;
194
+ for (const m of members)
195
+ covered.add(m.id);
196
+ addGroup(c.dimId, {
197
+ members: members.map((m) => m.finding),
198
+ sampleIdxs: new Set(members.map((m) => m.sampleIdx)),
199
+ override: { description: c.description, suggestion: c.suggestion, level: c.level },
200
+ });
201
+ }
202
+ // 兜底:LLM 漏分配的 finding 自成一组,绝不丢。
203
+ for (const t of tagged) {
204
+ if (covered.has(t.id))
205
+ continue;
206
+ covered.add(t.id);
207
+ addGroup(t.dimId, { members: [t.finding], sampleIdxs: new Set([t.sampleIdx]) });
208
+ }
209
+ const byDimId = new Map();
210
+ for (const dim of dims) {
211
+ const present = presentSamplesFor(samples, dim.id);
212
+ if (present.length === 0) {
213
+ byDimId.set(dim.id, { level: '不适用', findings: [], suggestions: [], _missing: true });
214
+ continue;
215
+ }
216
+ const suggestionsSet = new Set();
217
+ for (const { r } of present)
218
+ for (const sug of r.suggestions)
219
+ suggestionsSet.add(sug);
220
+ const assessedN = present.filter((p) => p.r.level !== '不适用').length;
221
+ byDimId.set(dim.id, assembleDimResult(present.map((p) => p.r.level), assessedN, groupsByDim.get(dim.id) ?? [], [...suggestionsSet]));
222
+ }
223
+ return finalize(byDimId, samples);
224
+ }
@@ -19,6 +19,12 @@ export interface HealthFinding {
19
19
  evidence: string;
20
20
  description: string;
21
21
  suggestion?: string;
22
+ /** 多采样共识的支持度:N 次采样里有 k 次报了这条(同根因)finding。
23
+ * 单次采样(healthSamples=1)时不设或为 {k:1,n:1};renderer 仅在 n>1 时展示。 */
24
+ support?: {
25
+ k: number;
26
+ n: number;
27
+ };
22
28
  }
23
29
  export interface HealthDimensionResult {
24
30
  level: HealthDimensionLevel;
@@ -3,7 +3,6 @@
3
3
  * 注册到 doctor customRules。
4
4
  *
5
5
  * CLI doctor.ts 默认动态 import 本文件 → 副作用一次性完成。
6
- * `--static-only` 会在 CLI 层过滤掉 composer rule,不触发 LLM 审计。
7
6
  * 测试 import 各 module 的具名 export 不会触发本文件,保持 registry 干净。
8
7
  *
9
8
  * 用户自定义维度:在自己的代码里 `registerHealthDimension(spec)`,只要在
@@ -3,7 +3,6 @@
3
3
  * 注册到 doctor customRules。
4
4
  *
5
5
  * CLI doctor.ts 默认动态 import 本文件 → 副作用一次性完成。
6
- * `--static-only` 会在 CLI 层过滤掉 composer rule,不触发 LLM 审计。
7
6
  * 测试 import 各 module 的具名 export 不会触发本文件,保持 registry 干净。
8
7
  *
9
8
  * 用户自定义维度:在自己的代码里 `registerHealthDimension(spec)`,只要在
@@ -220,6 +220,9 @@ export async function runDoctor(opts) {
220
220
  timeoutMs: opts.timeoutMs,
221
221
  effort: opts.effort,
222
222
  runHealthCheck: opts.runHealthCheck ?? false,
223
+ healthSamples: opts.healthSamples,
224
+ healthMerge: opts.healthMerge,
225
+ healthConcurrency: opts.healthConcurrency,
223
226
  };
224
227
  const skillReports = [];
225
228
  const totals = { pass: 0, warn: 0, fail: 0 };
@@ -14,7 +14,7 @@ interface MessageEntry {
14
14
  zh: string;
15
15
  en: string;
16
16
  }
17
- export type DoctorMessageKey = 'cli.doctor.rule.skill_readable' | 'cli.doctor.rule.skill_metadata' | 'cli.doctor.rule.dependencies' | 'cli.doctor.rule.samples_contract' | 'cli.doctor.rule.skill_health_check' | 'cli.doctor.health.skipped' | 'cli.doctor.health.no_dimensions' | 'cli.doctor.health.fail.executor' | 'cli.doctor.health.fail.parse' | 'cli.doctor.health.fail.empty_output' | 'cli.doctor.health.hint.executor' | 'cli.doctor.health.hint.parse' | 'cli.doctor.health.dim.message' | 'cli.doctor.health.dim.missing' | 'cli.doctor.health.summary.label' | 'cli.doctor.health.summary.message' | 'cli.doctor.health.summary.no_top' | 'cli.doctor.health.dim.trigger-boundary' | 'cli.doctor.health.dim.doc-clarity' | 'cli.doctor.health.dim.instr-precision' | 'cli.doctor.health.dim.dependency' | 'cli.doctor.health.dim.tool-conventions' | 'cli.doctor.health.dim.security' | 'cli.doctor.health.dim.examples' | 'cli.doctor.skill_readable.pass' | 'cli.doctor.skill_metadata.pass' | 'cli.doctor.dependencies.pass' | 'cli.doctor.samples_contract.pass' | 'cli.doctor.skill_readable.fail.missing' | 'cli.doctor.skill_readable.fail.empty' | 'cli.doctor.skill_readable.fail.too_short' | 'cli.doctor.skill_readable.hint.missing' | 'cli.doctor.skill_readable.hint.too_short' | 'cli.doctor.skill_metadata.fail.frontmatter_invalid' | 'cli.doctor.skill_metadata.fail.hardrules_invalid' | 'cli.doctor.skill_metadata.fail.workflows_invalid' | 'cli.doctor.skill_metadata.fail.missing_skillmd' | 'cli.doctor.skill_metadata.hint.frontmatter' | 'cli.doctor.skill_metadata.hint.hardrules' | 'cli.doctor.skill_metadata.hint.workflows' | 'cli.doctor.skill_metadata.hint.missing_skillmd' | 'cli.doctor.dependencies.fail' | 'cli.doctor.dependencies.hint.tool' | 'cli.doctor.dependencies.hint.file' | 'cli.doctor.dependencies.hint.env' | 'cli.doctor.dependencies.hint.preflight' | 'cli.doctor.dependencies.issue.tool_not_found' | 'cli.doctor.dependencies.issue.file_not_found' | 'cli.doctor.dependencies.issue.env_not_set' | 'cli.doctor.dependencies.issue.preflight_failed' | 'cli.doctor.samples_contract.skipped' | 'cli.doctor.samples_contract.warn.empty' | 'cli.doctor.samples_contract.warn.missing_prompt' | 'cli.doctor.samples_contract.hint';
17
+ export type DoctorMessageKey = 'cli.doctor.rule.skill_readable' | 'cli.doctor.rule.skill_metadata' | 'cli.doctor.rule.dependencies' | 'cli.doctor.rule.samples_contract' | 'cli.doctor.rule.skill_health_check' | 'cli.doctor.health.skipped' | 'cli.doctor.health.no_dimensions' | 'cli.doctor.health.fail.executor' | 'cli.doctor.health.fail.parse' | 'cli.doctor.health.fail.empty_output' | 'cli.doctor.health.hint.executor' | 'cli.doctor.health.hint.parse' | 'cli.doctor.health.dim.message' | 'cli.doctor.health.dim.missing' | 'cli.doctor.health.summary.label' | 'cli.doctor.health.summary.message' | 'cli.doctor.health.summary.samples' | 'cli.doctor.health.summary.samples_degraded' | 'cli.doctor.health.summary.no_top' | 'cli.doctor.health.dim.trigger-boundary' | 'cli.doctor.health.dim.doc-clarity' | 'cli.doctor.health.dim.instr-precision' | 'cli.doctor.health.dim.dependency' | 'cli.doctor.health.dim.tool-conventions' | 'cli.doctor.health.dim.security' | 'cli.doctor.health.dim.examples' | 'cli.doctor.skill_readable.pass' | 'cli.doctor.skill_metadata.pass' | 'cli.doctor.dependencies.pass' | 'cli.doctor.samples_contract.pass' | 'cli.doctor.skill_readable.fail.missing' | 'cli.doctor.skill_readable.fail.empty' | 'cli.doctor.skill_readable.fail.too_short' | 'cli.doctor.skill_readable.hint.missing' | 'cli.doctor.skill_readable.hint.too_short' | 'cli.doctor.skill_metadata.fail.frontmatter_invalid' | 'cli.doctor.skill_metadata.fail.hardrules_invalid' | 'cli.doctor.skill_metadata.fail.workflows_invalid' | 'cli.doctor.skill_metadata.fail.missing_skillmd' | 'cli.doctor.skill_metadata.hint.frontmatter' | 'cli.doctor.skill_metadata.hint.hardrules' | 'cli.doctor.skill_metadata.hint.workflows' | 'cli.doctor.skill_metadata.hint.missing_skillmd' | 'cli.doctor.dependencies.fail' | 'cli.doctor.dependencies.hint.tool' | 'cli.doctor.dependencies.hint.file' | 'cli.doctor.dependencies.hint.env' | 'cli.doctor.dependencies.hint.preflight' | 'cli.doctor.dependencies.issue.tool_not_found' | 'cli.doctor.dependencies.issue.file_not_found' | 'cli.doctor.dependencies.issue.env_not_set' | 'cli.doctor.dependencies.issue.preflight_failed' | 'cli.doctor.samples_contract.skipped' | 'cli.doctor.samples_contract.warn.empty' | 'cli.doctor.samples_contract.warn.missing_prompt' | 'cli.doctor.samples_contract.hint';
18
18
  export declare const DOCTOR_MESSAGES: Record<DoctorMessageKey, MessageEntry>;
19
19
  export declare function tDoctorMessage(key: DoctorMessageKey, lang?: Lang, params?: Record<string, string | number>): string;
20
20
  export {};
@@ -65,6 +65,14 @@ export const DOCTOR_MESSAGES = {
65
65
  zh: '{overall} | 维度: 健康 {h}/亚健康 {sh}/不健康 {bad}/不适用 {na} | finding: 错误 {err}/警告 {warn}/建议 {sug}',
66
66
  en: '{overall} | dims: healthy {h}/sub {sh}/unhealthy {bad}/n-a {na} | findings: err {err}/warn {warn}/sug {sug}',
67
67
  },
68
+ 'cli.doctor.health.summary.samples': {
69
+ zh: ' | 采样 {succeeded}/{requested} 次(并集去重,finding 标注 k/n 支持度)',
70
+ en: ' | sampled {succeeded}/{requested} (unioned & deduped; findings tagged k/n support)',
71
+ },
72
+ 'cli.doctor.health.summary.samples_degraded': {
73
+ zh: ' | 采样仅成功解析 {succeeded}/{requested} 次(已降级,finding 的 k/n 只基于成功样本)',
74
+ en: ' | parsed only {succeeded}/{requested} samples (degraded; k/n support uses successful samples only)',
75
+ },
68
76
  'cli.doctor.health.summary.no_top': {
69
77
  zh: '完整详情见 --json 输出或 --html 报告',
70
78
  en: 'Full detail in --json output or --html report',
@@ -2,8 +2,10 @@
2
2
  * omk doctor 内置规则注册表。
3
3
  *
4
4
  * BUILTIN_RULES 是**纯静态/低成本检查**, 不碰 LLM 连通性 — executor / judge
5
- * 连通性由 evaluation preflight 负责。用户入口 `omk doctor` 默认还会跑
6
- * skill_health composer 做 LLM 审计;`--static-only` 只暴露这里的静态规则。
5
+ * 连通性由 evaluation preflight 负责。用户入口 `omk doctor` 默认会跑这些静态
6
+ * 规则,再跑 skill_health composer 做 LLM 审计;`--static-only` 只跑静态规则。
7
+ * samples_contract_aligned 需要 samples,仍只归 eval preflight
8
+ * (run-evaluation.ts 经 runDoctor 调,dependencies_present 守依赖完整性)。
7
9
  *
8
10
  * 每条 rule 回答一个独立的「skill 能不能被有意义评测」子问题:
9
11
  * - skill_readable: 文件能读、内容非空且有最小长度
@@ -2,8 +2,10 @@
2
2
  * omk doctor 内置规则注册表。
3
3
  *
4
4
  * BUILTIN_RULES 是**纯静态/低成本检查**, 不碰 LLM 连通性 — executor / judge
5
- * 连通性由 evaluation preflight 负责。用户入口 `omk doctor` 默认还会跑
6
- * skill_health composer 做 LLM 审计;`--static-only` 只暴露这里的静态规则。
5
+ * 连通性由 evaluation preflight 负责。用户入口 `omk doctor` 默认会跑这些静态
6
+ * 规则,再跑 skill_health composer 做 LLM 审计;`--static-only` 只跑静态规则。
7
+ * samples_contract_aligned 需要 samples,仍只归 eval preflight
8
+ * (run-evaluation.ts 经 runDoctor 调,dependencies_present 守依赖完整性)。
7
9
  *
8
10
  * 每条 rule 回答一个独立的「skill 能不能被有意义评测」子问题:
9
11
  * - skill_readable: 文件能读、内容非空且有最小长度