oh-my-knowledge 0.41.0 → 0.43.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 (67) hide show
  1. package/README.md +7 -2
  2. package/README.zh.md +7 -2
  3. package/dist/analysis/report-diagnostics.d.ts +8 -1
  4. package/dist/analysis/report-diagnostics.js +82 -1
  5. package/dist/artifact-graph/doctor.d.ts +21 -0
  6. package/dist/artifact-graph/doctor.js +569 -0
  7. package/dist/assets/agent-skills/omk/SKILL.md +9 -9
  8. package/dist/assets/agent-skills/omk/references/commands.md +2 -1
  9. package/dist/authoring/evolver.d.ts +3 -14
  10. package/dist/authoring/evolver.js +1 -52
  11. package/dist/authoring/generator.d.ts +24 -0
  12. package/dist/authoring/generator.js +33 -6
  13. package/dist/cli/commands/doctor.js +62 -61
  14. package/dist/cli/commands/eval/index.d.ts +1 -0
  15. package/dist/cli/commands/eval/index.js +60 -7
  16. package/dist/cli/commands/init.js +11 -7
  17. package/dist/cli/commands/observe/index.d.ts +2 -2
  18. package/dist/cli/commands/observe/index.js +8 -7
  19. package/dist/cli/commands/sample.js +22 -16
  20. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  21. package/dist/cli/lib/i18n-dict/common.js +8 -0
  22. package/dist/cli/lib/i18n-dict/help.js +12 -10
  23. package/dist/cli/lib/i18n-dict/init.d.ts +1 -1
  24. package/dist/cli/lib/i18n-dict/init.js +14 -11
  25. package/dist/cli/lib/i18n-dict/run.d.ts +1 -1
  26. package/dist/cli/lib/i18n-dict/run.js +4 -0
  27. package/dist/cli/lib/parse-run-config/samples-discovery.d.ts +5 -7
  28. package/dist/cli/lib/parse-run-config/samples-discovery.js +10 -32
  29. package/dist/cli/lib/parse-run-config.d.ts +3 -0
  30. package/dist/cli/lib/parse-run-config.js +4 -4
  31. package/dist/cli/lib/resolve-skill-input.js +10 -12
  32. package/dist/doctor/messages.js +2 -2
  33. package/dist/eval-core/artifact-file-names.d.ts +15 -0
  34. package/dist/eval-core/artifact-file-names.js +46 -0
  35. package/dist/eval-core/evaluation-job.d.ts +2 -1
  36. package/dist/eval-core/evaluation-job.js +2 -1
  37. package/dist/eval-core/evaluation-reporting.js +9 -4
  38. package/dist/eval-core/holdout.d.ts +66 -0
  39. package/dist/eval-core/holdout.js +118 -0
  40. package/dist/eval-core/measurement-dirs.js +13 -7
  41. package/dist/eval-core/report-file-migration.d.ts +10 -0
  42. package/dist/eval-core/report-file-migration.js +90 -0
  43. package/dist/eval-core/verdict.d.ts +44 -1
  44. package/dist/eval-core/verdict.js +175 -13
  45. package/dist/eval-workflows/evaluation-pipeline/report-finalize.js +19 -1
  46. package/dist/eval-workflows/evaluation-pipeline/run-state.d.ts +2 -1
  47. package/dist/eval-workflows/evaluation-pipeline/run-state.js +2 -1
  48. package/dist/eval-workflows/evaluation-pipeline.d.ts +3 -1
  49. package/dist/eval-workflows/evaluation-pipeline.js +2 -1
  50. package/dist/eval-workflows/run-evaluation.d.ts +5 -2
  51. package/dist/eval-workflows/run-evaluation.js +8 -5
  52. package/dist/inputs/eval-config.js +6 -0
  53. package/dist/inputs/sample-locator.d.ts +23 -0
  54. package/dist/inputs/sample-locator.js +195 -0
  55. package/dist/inputs/skill-loader.js +7 -17
  56. package/dist/observability/inbox.js +7 -3
  57. package/dist/renderer/summary.js +36 -3
  58. package/dist/server/report-server.js +10 -4
  59. package/dist/server/report-store.js +17 -9
  60. package/dist/server/skill-index.js +16 -11
  61. package/dist/types/artifact-graph.d.ts +93 -0
  62. package/dist/types/artifact-graph.js +1 -0
  63. package/dist/types/eval.d.ts +7 -0
  64. package/dist/types/index.d.ts +1 -0
  65. package/dist/types/index.js +1 -0
  66. package/dist/types/report.d.ts +49 -0
  67. package/package.json +1 -1
@@ -0,0 +1,569 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { cardFileName, graphFileName, safeArtifactFileStem } from '../eval-core/artifact-file-names.js';
5
+ import { hashArtifactSource } from '../inputs/content-hash.js';
6
+ import { extractMarkdownStepWorkflows, extractSkillHardRules, extractSkillWorkflows, parseSkillFrontmatter, } from '../shared/hard-rules.js';
7
+ function shortHash(input) {
8
+ return createHash('sha256').update(input).digest('hex').slice(0, 12);
9
+ }
10
+ function safeFileName(id) {
11
+ return safeArtifactFileStem(id);
12
+ }
13
+ export function doctorGraphDirForDoctorOutput(doctorOutputDir) {
14
+ return basename(doctorOutputDir) === 'doctors'
15
+ ? join(dirname(doctorOutputDir), 'graphs', 'doctor')
16
+ : join(doctorOutputDir, 'graphs', 'doctor');
17
+ }
18
+ function normalizeRelPath(path) {
19
+ return path.split(sep).join('/');
20
+ }
21
+ function resolveSkillSource(skill) {
22
+ const source = resolve(skill.skillPath);
23
+ if (!existsSync(source))
24
+ return null;
25
+ const stat = statSync(source);
26
+ const skillFilePath = stat.isDirectory() ? join(source, 'SKILL.md') : source;
27
+ if (!existsSync(skillFilePath))
28
+ return null;
29
+ const isDirectorySkill = basename(skillFilePath) === 'SKILL.md';
30
+ const skillRoot = dirname(skillFilePath);
31
+ const content = readFileSync(skillFilePath, 'utf-8');
32
+ let artifactHash;
33
+ try {
34
+ artifactHash = hashArtifactSource(isDirectorySkill ? skillRoot : skillFilePath, isDirectorySkill);
35
+ }
36
+ catch {
37
+ artifactHash = undefined;
38
+ }
39
+ return { skillFilePath, skillRoot, isDirectorySkill, content, artifactHash };
40
+ }
41
+ function listFiles(root, subdir) {
42
+ const start = join(root, subdir);
43
+ if (!existsSync(start))
44
+ return [];
45
+ const out = [];
46
+ const walk = (dir) => {
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ const full = join(dir, entry.name);
49
+ if (entry.isDirectory()) {
50
+ walk(full);
51
+ }
52
+ else if (entry.isFile()) {
53
+ out.push(normalizeRelPath(relative(root, full)));
54
+ }
55
+ }
56
+ };
57
+ walk(start);
58
+ out.sort();
59
+ return out;
60
+ }
61
+ function statusFromRule(status) {
62
+ switch (status) {
63
+ case 'pass':
64
+ return 'ok';
65
+ case 'warn':
66
+ return 'warning';
67
+ case 'fail':
68
+ return 'failed';
69
+ case 'skipped':
70
+ return 'skipped';
71
+ }
72
+ }
73
+ function sourceKey(snapshot, skill) {
74
+ return snapshot?.artifactHash ?? `locator:${shortHash(skill.skillPath)}`;
75
+ }
76
+ function contentBinding(snapshot, skill) {
77
+ if (snapshot?.artifactHash) {
78
+ return { bindingStrength: 'content-hash', keys: { artifactHash: snapshot.artifactHash } };
79
+ }
80
+ return { bindingStrength: 'source-locator', keys: { sourceLocatorHash: shortHash(skill.skillPath) } };
81
+ }
82
+ function skillEvidence(snapshot, skill) {
83
+ if (!snapshot) {
84
+ return [{
85
+ sourceKind: 'skill-file',
86
+ path: skill.skillPath,
87
+ label: 'skill source',
88
+ }];
89
+ }
90
+ return [{
91
+ sourceKind: 'skill-file',
92
+ path: snapshot.skillFilePath,
93
+ selector: { selectorKind: 'line-range', value: '1-1' },
94
+ contentHash: snapshot.artifactHash,
95
+ label: 'SKILL.md',
96
+ }];
97
+ }
98
+ function stringList(value) {
99
+ if (Array.isArray(value))
100
+ return value.filter((item) => typeof item === 'string' && item.trim().length > 0);
101
+ if (typeof value === 'string' && value.trim())
102
+ return [value.trim()];
103
+ return [];
104
+ }
105
+ function nestedStringList(root, key) {
106
+ if (!root || typeof root !== 'object' || Array.isArray(root))
107
+ return [];
108
+ return stringList(root[key]);
109
+ }
110
+ function sampleCountFromDoctor(skill) {
111
+ for (const result of skill.results) {
112
+ if (result.ruleId !== 'samples_contract_aligned')
113
+ continue;
114
+ const count = result.detail?.count ?? result.detail?.totalCount;
115
+ return typeof count === 'number' && Number.isFinite(count) && count > 0 ? count : null;
116
+ }
117
+ return null;
118
+ }
119
+ export function buildDoctorArtifactGraph(options) {
120
+ const { report, skill, sourcePath } = options;
121
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
122
+ const snapshot = resolveSkillSource(skill);
123
+ const keyBase = sourceKey(snapshot, skill);
124
+ const binding = contentBinding(snapshot, skill);
125
+ const evidence = skillEvidence(snapshot, skill);
126
+ const nodes = [];
127
+ const edges = [];
128
+ const nodeIdsByStableKey = new Map();
129
+ const addNode = (stableKey, nodeKind, nodeRole, label, extra = {}) => {
130
+ const existing = nodeIdsByStableKey.get(stableKey);
131
+ if (existing)
132
+ return existing;
133
+ const id = `node:${shortHash(stableKey)}`;
134
+ nodeIdsByStableKey.set(stableKey, id);
135
+ nodes.push({
136
+ id,
137
+ stableKey,
138
+ nodeKind,
139
+ nodeRole,
140
+ layer: 'definition',
141
+ label,
142
+ ...extra,
143
+ });
144
+ return id;
145
+ };
146
+ const addEdge = (fromNodeId, toNodeId, edgeKind, extra = {}) => {
147
+ const id = `edge:${shortHash(`${fromNodeId}|${edgeKind}|${toNodeId}|${edges.length}`)}`;
148
+ edges.push({
149
+ id,
150
+ fromNodeId,
151
+ toNodeId,
152
+ edgeKind,
153
+ layer: 'definition',
154
+ ...extra,
155
+ });
156
+ };
157
+ const skillNodeId = addNode(`v1:skill:${keyBase}`, 'skill', 'entity', skill.skillName, {
158
+ binding,
159
+ attrs: { display: { sourceLocator: skill.skillPath } },
160
+ evidenceRefs: evidence,
161
+ });
162
+ const skillFileNodeId = addNode(`v1:skill-file:${keyBase}:SKILL.md`, 'skill_file', 'entity', 'SKILL.md', {
163
+ binding,
164
+ attrs: { display: { path: snapshot ? normalizeRelPath(relative(snapshot.skillRoot, snapshot.skillFilePath)) : skill.skillPath } },
165
+ evidenceRefs: evidence,
166
+ });
167
+ addEdge(skillNodeId, skillFileNodeId, 'contains', { evidenceRefs: evidence });
168
+ if (snapshot) {
169
+ const parsedFrontmatter = parseSkillFrontmatter(snapshot.content);
170
+ if (parsedFrontmatter.hasFrontmatter) {
171
+ const frontmatterNodeId = addNode(`v1:frontmatter:${keyBase}`, 'frontmatter', 'entity', 'frontmatter', {
172
+ status: parsedFrontmatter.ok ? 'ok' : 'failed',
173
+ binding,
174
+ evidenceRefs: evidence,
175
+ attrs: parsedFrontmatter.ok
176
+ ? { display: { fieldCount: Object.keys(parsedFrontmatter.data ?? {}).length } }
177
+ : { display: { error: parsedFrontmatter.error } },
178
+ });
179
+ addEdge(skillNodeId, frontmatterNodeId, 'declares', { status: parsedFrontmatter.ok ? 'ok' : 'failed' });
180
+ }
181
+ for (const rel of listFiles(snapshot.skillRoot, 'references')) {
182
+ const nodeId = addNode(`v1:reference:${keyBase}:${rel}`, 'reference', 'entity', rel, {
183
+ binding,
184
+ attrs: { display: { path: rel } },
185
+ evidenceRefs: [{ sourceKind: 'skill-file', path: join(snapshot.skillRoot, rel), contentHash: snapshot.artifactHash, label: rel }],
186
+ });
187
+ addEdge(skillNodeId, nodeId, 'references');
188
+ }
189
+ for (const rel of listFiles(snapshot.skillRoot, 'scripts')) {
190
+ const nodeId = addNode(`v1:script:${keyBase}:${rel}`, 'script', 'entity', rel, {
191
+ binding,
192
+ attrs: { display: { path: rel } },
193
+ evidenceRefs: [{ sourceKind: 'skill-file', path: join(snapshot.skillRoot, rel), contentHash: snapshot.artifactHash, label: rel }],
194
+ });
195
+ addEdge(skillNodeId, nodeId, 'contains');
196
+ }
197
+ const frontmatterData = parsedFrontmatter.ok ? parsedFrontmatter.data ?? {} : {};
198
+ const preflight = [
199
+ ...stringList(frontmatterData.preflight),
200
+ ...nestedStringList(frontmatterData.requires, 'preflight'),
201
+ ];
202
+ preflight.forEach((cmd, index) => {
203
+ const nodeId = addNode(`v1:preflight:${keyBase}:${shortHash(cmd)}`, 'preflight', 'entity', cmd, { binding, attrs: { display: { index } }, evidenceRefs: evidence });
204
+ addEdge(skillNodeId, nodeId, 'requires');
205
+ });
206
+ const tools = [
207
+ ...stringList(frontmatterData.tools),
208
+ ...stringList(frontmatterData.allowedTools),
209
+ ...nestedStringList(frontmatterData.requires, 'tools'),
210
+ ];
211
+ tools.forEach((tool) => {
212
+ const nodeId = addNode(`v1:tool:${tool}`, 'tool', 'entity', tool, { binding: { bindingStrength: 'name-only', keys: { toolName: tool } }, evidenceRefs: evidence });
213
+ addEdge(skillNodeId, nodeId, 'requires', { binding: { bindingStrength: 'name-only', keys: { toolName: tool } } });
214
+ });
215
+ const env = [
216
+ ...stringList(frontmatterData.env),
217
+ ...nestedStringList(frontmatterData.requires, 'env'),
218
+ ];
219
+ env.forEach((name) => {
220
+ const nodeId = addNode(`v1:env:${name}`, 'env', 'entity', name, { binding: { bindingStrength: 'name-only', keys: { envName: name } }, evidenceRefs: evidence });
221
+ addEdge(skillNodeId, nodeId, 'requires', { binding: { bindingStrength: 'name-only', keys: { envName: name } } });
222
+ });
223
+ for (const hardRule of extractSkillHardRules(snapshot.content)) {
224
+ const nodeId = addNode(`v1:hard-rule:${keyBase}:${hardRule.id}`, 'hard_rule', 'entity', hardRule.id, {
225
+ binding,
226
+ attrs: { display: { rule: hardRule.rule, expectedBehavior: hardRule.expectedBehavior } },
227
+ evidenceRefs: evidence,
228
+ });
229
+ addEdge(skillNodeId, nodeId, 'declares');
230
+ }
231
+ const workflows = [
232
+ ...extractSkillWorkflows(snapshot.content),
233
+ ...extractMarkdownStepWorkflows(snapshot.content),
234
+ ];
235
+ for (const workflow of workflows) {
236
+ const workflowNodeId = addNode(`v1:workflow:${keyBase}:${workflow.id}`, 'workflow', 'entity', workflow.id, {
237
+ binding,
238
+ attrs: { display: { description: workflow.description, source: workflow.source ?? 'frontmatter' } },
239
+ evidenceRefs: evidence,
240
+ });
241
+ addEdge(skillNodeId, workflowNodeId, 'declares');
242
+ let prevNodeId = null;
243
+ for (const node of workflow.nodes) {
244
+ const stepNodeId = addNode(`v1:workflow-node:${keyBase}:${workflow.id}.${node.id}`, 'workflow_node', 'entity', node.action, {
245
+ binding,
246
+ attrs: { display: { workflowId: workflow.id, nodeId: node.id } },
247
+ evidenceRefs: evidence,
248
+ });
249
+ addEdge(workflowNodeId, stepNodeId, 'defines_workflow');
250
+ if (prevNodeId)
251
+ addEdge(prevNodeId, stepNodeId, 'next_step');
252
+ prevNodeId = stepNodeId;
253
+ }
254
+ }
255
+ }
256
+ skill.results.forEach((result, index) => {
257
+ const nodeId = addNode(`v1:doctor-result:${report.id}:${skill.skillName}:${result.ruleId}`, 'doctor_rule_result', 'observation', result.ruleId, {
258
+ status: statusFromRule(result.status),
259
+ metrics: { durationMs: result.durationMs },
260
+ attrs: {
261
+ display: {
262
+ severity: result.severity,
263
+ message: result.message,
264
+ hint: result.hint,
265
+ groupId: result.groupId,
266
+ },
267
+ },
268
+ evidenceRefs: [{
269
+ sourceKind: 'doctor-report',
270
+ sourceId: report.id,
271
+ path: sourcePath,
272
+ selector: { selectorKind: 'json-pointer', value: `/skills/0/results/${index}` },
273
+ label: result.ruleId,
274
+ }],
275
+ });
276
+ addEdge(nodeId, skillNodeId, 'diagnoses', {
277
+ status: statusFromRule(result.status),
278
+ evidenceRefs: [{
279
+ sourceKind: 'doctor-report',
280
+ sourceId: report.id,
281
+ path: sourcePath,
282
+ selector: { selectorKind: 'rule-id', value: result.ruleId },
283
+ label: result.ruleId,
284
+ }],
285
+ });
286
+ });
287
+ const structureCounts = countDoctorGraphStructure(nodes);
288
+ const severity = skill.status === 'fail' ? 'high' : skill.status === 'warn' ? 'medium' : 'info';
289
+ return {
290
+ documentKind: 'artifact-graph',
291
+ schemaVersion: 1,
292
+ graphId: `doctor:${report.id}:${skill.skillName}`,
293
+ generatedAt,
294
+ source: {
295
+ sourceKind: 'doctor',
296
+ sourceId: report.id,
297
+ sourcePath,
298
+ cliVersion: report.cliVersion,
299
+ },
300
+ scope: {
301
+ cwd: report.cwd,
302
+ artifactKind: 'skill',
303
+ skillName: skill.skillName,
304
+ ...(snapshot?.artifactHash ? { artifactHash: snapshot.artifactHash } : {}),
305
+ sourceLocator: skill.skillPath,
306
+ },
307
+ nodes,
308
+ edges,
309
+ summaries: [{
310
+ summaryKind: 'structure',
311
+ title: `references=${structureCounts.references}, scripts=${structureCounts.scripts}, workflows=${structureCounts.workflows}, workflowNodes=${structureCounts.workflowNodes}`,
312
+ severity: 'info',
313
+ nodeIds: [skillNodeId],
314
+ }, {
315
+ summaryKind: 'risk',
316
+ title: `doctor status: ${skill.status}`,
317
+ severity,
318
+ nodeIds: nodes.filter((node) => node.nodeKind === 'doctor_rule_result').map((node) => node.id),
319
+ }],
320
+ };
321
+ }
322
+ function countDoctorGraphStructure(nodes) {
323
+ return {
324
+ references: nodes.filter((node) => node.nodeKind === 'reference').length,
325
+ scripts: nodes.filter((node) => node.nodeKind === 'script').length,
326
+ workflows: nodes.filter((node) => node.nodeKind === 'workflow').length,
327
+ workflowNodes: nodes.filter((node) => node.nodeKind === 'workflow_node').length,
328
+ hardRules: nodes.filter((node) => node.nodeKind === 'hard_rule').length,
329
+ };
330
+ }
331
+ function doctorStatusLabel(skill, lang) {
332
+ if (lang === 'zh') {
333
+ if (skill.status === 'pass')
334
+ return '已通过';
335
+ if (skill.status === 'warn')
336
+ return '有警告';
337
+ return '未通过';
338
+ }
339
+ if (skill.status === 'pass')
340
+ return 'passed';
341
+ if (skill.status === 'warn')
342
+ return 'warnings';
343
+ return 'failed';
344
+ }
345
+ function mermaidLabel(label) {
346
+ const entities = {
347
+ '&': '&',
348
+ '\\': '\',
349
+ '"': '"',
350
+ '[': '[',
351
+ ']': ']',
352
+ '(': '(',
353
+ ')': ')',
354
+ '{': '{',
355
+ '}': '}',
356
+ '|': '|',
357
+ '#': '#',
358
+ ';': '&#59;',
359
+ '<': '&lt;',
360
+ '>': '&gt;',
361
+ };
362
+ return label
363
+ .replace(/\s+/g, ' ')
364
+ .trim()
365
+ .replace(/[&\\"[\](){}|#;<>]/g, (ch) => entities[ch] ?? ch);
366
+ }
367
+ function compactLabel(label, max = 56) {
368
+ const normalized = label.replace(/\s+/g, ' ').trim();
369
+ return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized;
370
+ }
371
+ function doctorProblemResults(skill) {
372
+ return skill.results.filter((result) => result.status === 'fail' || result.status === 'warn');
373
+ }
374
+ function doctorProblemLabel(result, lang) {
375
+ if (!result)
376
+ return null;
377
+ const zh = lang === 'zh';
378
+ const prefix = result.status === 'fail'
379
+ ? (zh ? '失败' : 'fail')
380
+ : (zh ? '警告' : 'warn');
381
+ return zh ? `${prefix}:${result.ruleId}` : `${prefix}: ${result.ruleId}`;
382
+ }
383
+ function addExpandedGroup(lines, parentNodeId, groupNodeId, groupLabel, items, lang, max = 3) {
384
+ if (items.length === 0)
385
+ return;
386
+ lines.push(` ${groupNodeId}["${mermaidLabel(groupLabel)}"]`);
387
+ lines.push(` ${parentNodeId} --> ${groupNodeId}`);
388
+ const visible = items.slice(0, max);
389
+ visible.forEach((item, index) => {
390
+ const itemNodeId = `${groupNodeId}_${index + 1}`;
391
+ lines.push(` ${itemNodeId}["${mermaidLabel(compactLabel(item, 42))}"]`);
392
+ lines.push(` ${groupNodeId} --> ${itemNodeId}`);
393
+ });
394
+ const rest = items.length - visible.length;
395
+ if (rest > 0) {
396
+ const moreLabel = lang === 'zh' ? `另有 ${rest} 项` : `+${rest} more`;
397
+ lines.push(` ${groupNodeId}_more["${mermaidLabel(moreLabel)}"]`);
398
+ lines.push(` ${groupNodeId} --> ${groupNodeId}_more`);
399
+ }
400
+ }
401
+ function renderMermaid(graph, skill, lang) {
402
+ const references = graphLabelsByKind(graph, 'reference');
403
+ const scripts = graphLabelsByKind(graph, 'script');
404
+ const workflows = graphLabelsByKind(graph, 'workflow');
405
+ const problemLabel = doctorProblemLabel(doctorProblemResults(skill)[0], lang);
406
+ const lines = [
407
+ '```mermaid',
408
+ 'flowchart LR',
409
+ ` file["SKILL.md: ${mermaidLabel(skill.skillName)}"]`,
410
+ ` doctor["doctor / ${mermaidLabel(doctorStatusLabel(skill, lang))}"]`,
411
+ ];
412
+ addExpandedGroup(lines, 'file', 'refs', `references / ${references.length}`, references, lang);
413
+ addExpandedGroup(lines, 'file', 'scripts', `scripts / ${scripts.length}`, scripts, lang);
414
+ addExpandedGroup(lines, 'file', 'workflows', `workflows / ${workflows.length}`, workflows, lang);
415
+ lines.push(' file --> doctor');
416
+ if (problemLabel) {
417
+ lines.push(` issue["${mermaidLabel(compactLabel(problemLabel))}"]`);
418
+ lines.push(' doctor --> issue');
419
+ }
420
+ lines.push(` eval["${lang === 'zh' ? 'eval 未测量' : 'eval not measured'}"]`);
421
+ lines.push(` observe["${lang === 'zh' ? 'observe 未接入' : 'observe not connected'}"]`);
422
+ lines.push(' doctor -. next .-> eval');
423
+ lines.push(' eval -. next .-> observe');
424
+ lines.push(' classDef pending fill:#f3f4f6,stroke:#9ca3af,color:#6b7280');
425
+ lines.push(' class eval,observe pending');
426
+ lines.push('```');
427
+ return lines.join('\n');
428
+ }
429
+ function graphLabelsByKind(graph, kind) {
430
+ return graph.nodes
431
+ .filter((node) => node.nodeKind === kind)
432
+ .map((node) => node.label)
433
+ .sort((a, b) => a.localeCompare(b));
434
+ }
435
+ function formatInlineItems(items, lang, max = 8) {
436
+ if (items.length === 0)
437
+ return lang === 'zh' ? '无' : 'none';
438
+ const visible = items.slice(0, max).map((item) => `\`${item}\``).join(lang === 'zh' ? '、' : ', ');
439
+ const rest = items.length - max;
440
+ if (rest <= 0)
441
+ return visible;
442
+ return lang === 'zh' ? `${visible},另有 ${rest} 项` : `${visible}, plus ${rest} more`;
443
+ }
444
+ function renderStructureDetails(graph, lang) {
445
+ const zh = lang === 'zh';
446
+ const references = graphLabelsByKind(graph, 'reference');
447
+ const scripts = graphLabelsByKind(graph, 'script');
448
+ const workflows = graphLabelsByKind(graph, 'workflow');
449
+ if (references.length <= 3 && scripts.length <= 3 && workflows.length <= 3)
450
+ return [];
451
+ return [
452
+ zh ? '### 图中未展开的结构' : '### Hidden Structure',
453
+ '',
454
+ ...(references.length > 3 ? [`- references:${formatInlineItems(references.slice(3), lang)}`] : []),
455
+ ...(scripts.length > 3 ? [`- scripts:${formatInlineItems(scripts.slice(3), lang)}`] : []),
456
+ ...(workflows.length > 3 ? [`- workflows:${formatInlineItems(workflows.slice(3), lang)}`] : []),
457
+ ];
458
+ }
459
+ function renderDoctorFindings(skill, lang) {
460
+ const zh = lang === 'zh';
461
+ const problems = doctorProblemResults(skill).slice(0, 5);
462
+ if (problems.length === 0) {
463
+ return [
464
+ zh ? '### Doctor 发现' : '### Doctor Findings',
465
+ '',
466
+ zh ? '- 暂无失败或警告。' : '- No failures or warnings.',
467
+ ];
468
+ }
469
+ return [
470
+ zh ? '### Doctor 发现' : '### Doctor Findings',
471
+ '',
472
+ ...problems.map((result) => {
473
+ const label = doctorProblemLabel(result, lang) ?? result.ruleId;
474
+ const message = result.message ? `:${compactLabel(result.message, 160)}` : '';
475
+ const hint = result.hint ? (zh ? `;建议:${compactLabel(result.hint, 120)}` : `; hint: ${compactLabel(result.hint, 120)}`) : '';
476
+ return `- ${label}${message}${hint}`;
477
+ }),
478
+ ];
479
+ }
480
+ function shellQuotePath(path) {
481
+ if (/^[A-Za-z0-9_./:@%+-]+$/.test(path))
482
+ return path;
483
+ return JSON.stringify(path);
484
+ }
485
+ export function renderDoctorEvidenceCard(graph, skill, lang) {
486
+ const zh = lang === 'zh';
487
+ const counts = countDoctorGraphStructure(graph.nodes);
488
+ const sampleCount = sampleCountFromDoctor(skill);
489
+ const doctorStatus = doctorStatusLabel(skill, lang);
490
+ const sampleText = sampleCount == null ? (zh ? '未检测到 eval samples' : 'eval samples not detected') : `${sampleCount}`;
491
+ const source = graph.scope.sourceLocator ?? skill.skillPath;
492
+ const sourceCmd = shellQuotePath(source);
493
+ const statusSentence = zh
494
+ ? `这个 skill 有 ${counts.references} 个 references、${counts.scripts} 个 scripts、${counts.workflows} 个 workflows、${counts.workflowNodes} 个 workflow nodes,当前处于「doctor ${doctorStatus},eval 未测量,observe 未观察」状态。`
495
+ : `This skill has ${counts.references} references, ${counts.scripts} scripts, ${counts.workflows} workflows, and ${counts.workflowNodes} workflow nodes. Current state: doctor ${doctorStatus}, eval not measured, observe not observed.`;
496
+ const nextSteps = zh
497
+ ? [
498
+ sampleCount == null ? `- 生成用例:\`omk sample ${sourceCmd}\`` : `- 复用当前 ${sampleCount} 条用例继续评测。`,
499
+ `- 测量效果:\`omk eval --control baseline --treatment ${sourceCmd}\``,
500
+ '- 接入生产观察:`omk observe ingest <trace-dir>`',
501
+ ]
502
+ : [
503
+ sampleCount == null ? `- Generate samples: \`omk sample ${sourceCmd}\`` : `- Reuse the current ${sampleCount} sample(s) for eval.`,
504
+ `- Measure impact: \`omk eval --control baseline --treatment ${sourceCmd}\``,
505
+ '- Add production observation: `omk observe ingest <trace-dir>`',
506
+ ];
507
+ const hiddenStructure = renderStructureDetails(graph, lang);
508
+ return [
509
+ `## ${zh ? 'Skill Evidence Card' : 'Skill Evidence Card'}:${skill.skillName}`,
510
+ '',
511
+ statusSentence,
512
+ '',
513
+ renderMermaid(graph, skill, lang),
514
+ '',
515
+ ...hiddenStructure,
516
+ ...(hiddenStructure.length > 0 ? [''] : []),
517
+ ...renderDoctorFindings(skill, lang),
518
+ '',
519
+ zh ? '### 三阶段状态' : '### Stage Status',
520
+ '',
521
+ `| ${zh ? '阶段' : 'Stage'} | ${zh ? '状态' : 'Status'} |`,
522
+ '| --- | --- |',
523
+ `| doctor | ${doctorStatus} |`,
524
+ `| eval | ${zh ? '未测量' : 'not measured'} |`,
525
+ `| observe | ${zh ? '未观察' : 'not observed'} |`,
526
+ '',
527
+ zh ? '### 关键计数' : '### Key Counts',
528
+ '',
529
+ `| references | scripts | workflows | workflow nodes | hard rules | samples | doctor warnings | doctor failures |`,
530
+ '| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
531
+ `| ${counts.references} | ${counts.scripts} | ${counts.workflows} | ${counts.workflowNodes} | ${counts.hardRules} | ${sampleText} | ${skill.results.filter((r) => r.status === 'warn').length} | ${skill.results.filter((r) => r.status === 'fail').length} |`,
532
+ '',
533
+ zh ? '### 下一步' : '### Next Steps',
534
+ '',
535
+ ...nextSteps,
536
+ '',
537
+ zh ? '### 复现信息' : '### Reproduction',
538
+ '',
539
+ `- source:\`${source}\``,
540
+ `- artifactHash:\`${graph.scope.artifactHash ?? 'unknown'}\``,
541
+ `- doctorReportId:\`${graph.source.sourceId}\``,
542
+ `- graphId:\`${graph.graphId}\``,
543
+ `- generatedAt:\`${graph.generatedAt}\``,
544
+ '',
545
+ ].join('\n');
546
+ }
547
+ export function persistDoctorGraphSidecars(options) {
548
+ const dir = doctorGraphDirForDoctorOutput(options.outputDir);
549
+ mkdirSync(dir, { recursive: true });
550
+ const graph = buildDoctorArtifactGraph(options);
551
+ const fileStem = safeFileName(options.fileStem);
552
+ const graphPath = join(dir, graphFileName(fileStem));
553
+ const evidenceCardPath = join(dir, cardFileName(fileStem));
554
+ writeFileSync(graphPath, JSON.stringify(graph, null, 2), 'utf8');
555
+ writeFileSync(evidenceCardPath, renderDoctorEvidenceCard(graph, options.skill, options.lang), 'utf8');
556
+ return { graphPath, evidenceCardPath };
557
+ }
558
+ export function removeDoctorGraphSidecars(doctorOutputDir, fileStem) {
559
+ const dir = doctorGraphDirForDoctorOutput(doctorOutputDir);
560
+ const safeStem = safeFileName(fileStem);
561
+ for (const ext of ['graph.json', 'card.md']) {
562
+ try {
563
+ unlinkSync(join(dir, `${safeStem}.${ext}`));
564
+ }
565
+ catch {
566
+ // best-effort cleanup only
567
+ }
568
+ }
569
+ }
@@ -36,20 +36,20 @@ omk CLI 顶层命令包括:`init` / `install` / `list` / `promote` / `rollback
36
36
  | 查看受管 skill 状态 | → `omk list` |
37
37
  | 按证据接受 / 回退某版本 | → `omk promote` / `omk rollback` |
38
38
 
39
- 如果用户意图不明确,先扫描当前项目结构(skills/ 目录和 eval-samples 文件),然后推荐最合适的操作。
39
+ 如果用户意图不明确,先扫描当前项目结构(skills/ 目录、项目级 eval-samples 文件、skill 私有 `.omk/samples.*`),然后推荐最合适的操作。
40
40
 
41
41
  ## 第三步:检测项目结构
42
42
 
43
43
  使用 Glob 和 Read 工具检查:
44
44
 
45
45
  1. `skills/` 目录下有哪些 skill 文件(`.md` 或 `*/SKILL.md`)
46
- 2. 是否存在 `eval-samples.json` / `eval-samples.yaml` / `eval-samples.yml` / `<skill>/.omk/samples.json`(推荐的标准位置)
47
- 3. 是否有 `skills/*.eval-samples.json`(每 skill 配对文件 → `--batch` 模式)
46
+ 2. 是否存在项目级 `eval-samples.json` / `eval-samples.yaml` / `eval-samples.yml`,或目录 skill 私有的 `<skill>/.omk/samples.json`
47
+ 3. 是否有 `skills/*.eval-samples.json`(扁平 skill 的每 skill 配对文件 → `--batch` 模式)
48
48
 
49
49
  根据检测结果决定:
50
50
 
51
- - 多个 skill + 各自的 eval-samples → 建议 `--batch` 批量模式
52
- - 多个 skill + 共享 eval-samples → 建议版本对比模式
51
+ - 多个 skill + 各自的 `.omk/samples.*` 或扁平 skill paired eval-samples → 建议 `--batch` 批量模式
52
+ - 多个 skill + 共享项目级 eval-samples → 建议版本对比模式
53
53
  - 只有一个 skill → 建议 `baseline` 对照(`omk eval --control baseline --treatment <skill>`)或 `omk evolve` 改进
54
54
  - 没有 eval-samples → 先 `omk sample <skill>` 生成
55
55
 
@@ -104,19 +104,19 @@ evolve 默认开**显著性接受门**:候选只在相对当前最优**统计
104
104
 
105
105
  ```bash
106
106
  # 为单个 skill 生成
107
- omk sample skills/my-skill.md
107
+ omk sample skills/my-skill/SKILL.md
108
108
 
109
109
  # 显式指定数量(不指定时 LLM 根据 skill 类型自动决定 4-8 条)
110
- omk sample skills/my-skill.md --count 8
110
+ omk sample skills/my-skill/SKILL.md --count 8
111
111
 
112
112
  # 自然语言指定重点覆盖场景
113
- omk sample skills/my-skill.md --focus "重点覆盖搜索失败 / 权限拒绝 / 跨工具 fallback 路径"
113
+ omk sample skills/my-skill/SKILL.md --focus "重点覆盖搜索失败 / 权限拒绝 / 跨工具 fallback 路径"
114
114
 
115
115
  # 为 skill 目录下所有缺测试集的 skill 批量生成
116
116
  omk sample --batch
117
117
  ```
118
118
 
119
- 输出位置:`<skill>/SKILL.md` 风格 → `<skill>/.omk/samples.json`(标准),其他 `.md` 路径 → 当前目录 `eval-samples.json`(兜底)。
119
+ 输出位置:目录 skill(`<skill>/SKILL.md`)→ `<skill>/.omk/samples.json`(标准);扁平 `.md` 单次生成 → 当前目录 `eval-samples.json`(项目级兜底);扁平 `.md` 的 `--batch` 兼容生成 `<skill-dir>/<name>.eval-samples.json`。
120
120
 
121
121
  ### 体检 skill 写法
122
122
 
@@ -81,6 +81,7 @@ omk eval [flags]
81
81
  - `--executor` `option`:执行器:claude / claude-sdk / codex / codex-sdk / openai-api / gemini / 自定义命令(默认 claude)。
82
82
  - `--global` `boolean`:报告写全局 ~/.oh-my-knowledge/reports,而非项目 .omk/
83
83
  - `--gold-dir` `option`:gold dataset 目录
84
+ - `--holdout-ratio` `option`:留出比例 0-1(如 0.3);切出 holdout 子集,对比 train/holdout 综合分检测过拟合
84
85
  - `--judge-models` `option`:评委配置,格式 executor:model[,...],例 claude:haiku 或 claude:opus,openai-api:gpt-4o(≥ 2 个 = ensemble)。默认 <executor>:haiku。
85
86
  - `--judge-repeat` `option`:每个 dim 评 N 次
86
87
  - `--lang` `option` (默认 `zh`):输出语言 zh|en,优先级 CLI > OMK_LANG env > zh。
@@ -100,7 +101,7 @@ omk eval [flags]
100
101
  - `--report-only` `boolean`:生成报告并打印 verdict,但始终 exit 0(不参与 CI gate)。
101
102
  - `--resume` `option`:从某次失败 run 续跑
102
103
  - `--retry` `option`:失败 sample 重试次数
103
- - `--samples` `option`:用例文件路径。默认 eval-samples.json,也接受 .yaml/.yml;自动发现 --skill-dir 下的 <skill>/.omk/samples.json。
104
+ - `--samples` `option`:用例文件路径。默认项目级 eval-samples.json,也接受 .yaml/.yml;单 treatment 时可自动发现 <skill>/.omk/。
104
105
  - `--skill-dir` `option`:skill 目录,默认 skills
105
106
  - `--skip-connectivity` `boolean`:跳 LLM 连通性预检
106
107
  - `--skip-doctor` `boolean`:escape hatch:跳 doctor 健康检查门禁(默认强制启用)。沙箱 mock 提供依赖时绕开 doctor 物理路径误报;garbage-in 风险自负。
@@ -16,14 +16,11 @@ interface WeakSample {
16
16
  };
17
17
  }
18
18
  export declare function extractWeakSamples(report: Report, variantKey: string, count?: number, sampleIdFilter?: Set<string>): WeakSample[];
19
- /** A train / holdout partition of a sample set. */
20
- interface HoldoutSplit {
21
- trainIds: Set<string>;
22
- holdoutIds: Set<string>;
23
- }
24
19
  /** A train / val / test partition. `val` drives the accept decision; `test` is
25
20
  * locked — never seen during the loop, read once at the end for an unbiased
26
- * generalization score. */
21
+ * generalization score. Two-way holdout (`splitHoldout`), the stride picker
22
+ * (`pickByStride`), `MIN_HOLDOUT_SUBSET`, and `subsetCompositeScore` live in
23
+ * `src/eval-core/holdout.ts` so `omk eval --holdout-ratio` reuses them. */
27
24
  interface TrainValTestSplit {
28
25
  trainIds: Set<string>;
29
26
  valIds: Set<string>;
@@ -34,14 +31,6 @@ interface TrainValTestSplit {
34
31
  * every candidate. Under that floor evolve degrades to the point-estimate accept
35
32
  * and flags `gate.underpowered`. */
36
33
  export declare const MIN_GATE_SAMPLES = 8;
37
- /**
38
- * Deterministically split sample ids into train / holdout by `ratio` (fraction
39
- * held out). Holdout members are picked at an even stride so the partition is
40
- * representative of the ordering, and the split is stable across rounds and runs
41
- * (no RNG). Returns null when ratio ≤ 0 or either side would drop below
42
- * MIN_HOLDOUT_SUBSET — the caller then scores on the full set.
43
- */
44
- export declare function splitHoldout(sampleIds: string[], ratio: number): HoldoutSplit | null;
45
34
  /**
46
35
  * Deterministically split sample ids into train / val / test. `val` is carved
47
36
  * first at an even stride; `test` is carved at an even stride over what remains,