oh-my-knowledge 0.42.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 (38) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/artifact-graph/doctor.d.ts +21 -0
  4. package/dist/artifact-graph/doctor.js +569 -0
  5. package/dist/assets/agent-skills/omk/SKILL.md +9 -9
  6. package/dist/assets/agent-skills/omk/references/commands.md +1 -1
  7. package/dist/cli/commands/doctor.js +62 -61
  8. package/dist/cli/commands/eval/index.js +20 -2
  9. package/dist/cli/commands/init.js +1 -0
  10. package/dist/cli/commands/observe/index.d.ts +2 -2
  11. package/dist/cli/commands/observe/index.js +8 -7
  12. package/dist/cli/commands/sample.js +22 -16
  13. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  14. package/dist/cli/lib/i18n-dict/common.js +8 -0
  15. package/dist/cli/lib/i18n-dict/help.js +12 -10
  16. package/dist/cli/lib/parse-run-config/samples-discovery.d.ts +5 -7
  17. package/dist/cli/lib/parse-run-config/samples-discovery.js +10 -32
  18. package/dist/cli/lib/parse-run-config.js +4 -4
  19. package/dist/cli/lib/resolve-skill-input.js +10 -12
  20. package/dist/doctor/messages.js +2 -2
  21. package/dist/eval-core/artifact-file-names.d.ts +15 -0
  22. package/dist/eval-core/artifact-file-names.js +46 -0
  23. package/dist/eval-core/evaluation-reporting.js +2 -1
  24. package/dist/eval-core/measurement-dirs.js +13 -7
  25. package/dist/eval-core/report-file-migration.d.ts +10 -0
  26. package/dist/eval-core/report-file-migration.js +90 -0
  27. package/dist/inputs/sample-locator.d.ts +23 -0
  28. package/dist/inputs/sample-locator.js +195 -0
  29. package/dist/inputs/skill-loader.js +7 -17
  30. package/dist/observability/inbox.js +7 -3
  31. package/dist/server/report-server.js +10 -4
  32. package/dist/server/report-store.js +17 -9
  33. package/dist/server/skill-index.js +16 -11
  34. package/dist/types/artifact-graph.d.ts +93 -0
  35. package/dist/types/artifact-graph.js +1 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/index.js +1 -0
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -31,7 +31,7 @@ Prerequisite: the default executor and judge use the `claude` CLI — install an
31
31
 
32
32
  > The CLI notifies you when a newer version is available (at most once per 20h); set `OMK_SKIP_UPDATE_CHECK=1` to silence it permanently.
33
33
 
34
- Walkthrough: [5-minute quickstart guide](docs/quickstart-skill-eval.md) (recommended for first-time users). More runnable examples (A/B, offline executor, batch, evolve, agent, RAG) live in the repo's [example gallery](https://github.com/lizhiyao/oh-my-knowledge/tree/main/examples).
34
+ Walkthrough: [5-minute quickstart guide](docs/quickstart-skill-eval.md) (recommended for first-time users). More runnable examples (Skill Map, A/B, offline executor, agent runtime, RAG) live in the repo's [example gallery](https://github.com/lizhiyao/oh-my-knowledge/tree/main/examples).
35
35
 
36
36
  Deeper: [who omk is for](docs/explanation/who-omk-is-for.md) · [CLI reference](docs/reference/cli.md) · [how it works](docs/explanation/architecture.md) · [eval sample format](docs/reference/eval-sample-format.md) · [executors](docs/reference/executors.md) · [artifact layout](docs/reference/artifact-layout.md)
37
37
 
package/README.zh.md CHANGED
@@ -31,7 +31,7 @@ omk eval --control code-review-v1 --treatment code-review-v2
31
31
 
32
32
  > 命令行有新版本时会自动提示(每 20 小时最多一次);想永久关闭该提醒,设环境变量 `OMK_SKIP_UPDATE_CHECK=1` 即可。
33
33
 
34
- 手把手教程:[5 分钟快速上手](docs/zh/quickstart-skill-eval.md)(推荐第一次跑评测的用户)。更多可跑示例(A/B、离线执行器、batch、evolve、agent、RAG)见仓库的[示例画廊](https://github.com/lizhiyao/oh-my-knowledge/tree/main/examples)。
34
+ 手把手教程:[5 分钟快速上手](docs/zh/quickstart-skill-eval.md)(推荐第一次跑评测的用户)。更多可跑示例(Skill Map、A/B、离线执行器、agent runtime、RAG)见仓库的[示例画廊](https://github.com/lizhiyao/oh-my-knowledge/tree/main/examples)。
35
35
 
36
36
  深入:[为谁、解决什么](docs/zh/explanation/who-omk-is-for.md) · [CLI 参考](docs/zh/reference/cli.md) · [工作原理](docs/zh/explanation/architecture.md) · [评测用例格式](docs/zh/reference/eval-sample-format.md) · [执行器](docs/zh/reference/executors.md) · [artifact 布局](docs/zh/reference/artifact-layout.md)
37
37
 
@@ -0,0 +1,21 @@
1
+ import type { ArtifactGraphDocument, DoctorReport, DoctorSkillReport, Lang } from '../types/index.js';
2
+ export interface BuildDoctorGraphOptions {
3
+ report: DoctorReport;
4
+ skill: DoctorSkillReport;
5
+ sourcePath: string;
6
+ generatedAt?: string;
7
+ }
8
+ export interface PersistDoctorGraphOptions extends BuildDoctorGraphOptions {
9
+ outputDir: string;
10
+ fileStem: string;
11
+ lang: Lang;
12
+ }
13
+ export interface PersistDoctorGraphResult {
14
+ graphPath: string;
15
+ evidenceCardPath: string;
16
+ }
17
+ export declare function doctorGraphDirForDoctorOutput(doctorOutputDir: string): string;
18
+ export declare function buildDoctorArtifactGraph(options: BuildDoctorGraphOptions): ArtifactGraphDocument;
19
+ export declare function renderDoctorEvidenceCard(graph: ArtifactGraphDocument, skill: DoctorSkillReport, lang: Lang): string;
20
+ export declare function persistDoctorGraphSidecars(options: PersistDoctorGraphOptions): PersistDoctorGraphResult;
21
+ export declare function removeDoctorGraphSidecars(doctorOutputDir: string, fileStem: string): void;
@@ -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
 
@@ -101,7 +101,7 @@ omk eval [flags]
101
101
  - `--report-only` `boolean`:生成报告并打印 verdict,但始终 exit 0(不参与 CI gate)。
102
102
  - `--resume` `option`:从某次失败 run 续跑
103
103
  - `--retry` `option`:失败 sample 重试次数
104
- - `--samples` `option`:用例文件路径。默认 eval-samples.json,也接受 .yaml/.yml;自动发现 --skill-dir 下的 <skill>/.omk/samples.json。
104
+ - `--samples` `option`:用例文件路径。默认项目级 eval-samples.json,也接受 .yaml/.yml;单 treatment 时可自动发现 <skill>/.omk/。
105
105
  - `--skill-dir` `option`:skill 目录,默认 skills
106
106
  - `--skip-connectivity` `boolean`:跳 LLM 连通性预检
107
107
  - `--skip-doctor` `boolean`:escape hatch:跳 doctor 健康检查门禁(默认强制启用)。沙箱 mock 提供依赖时绕开 doctor 物理路径误报;garbage-in 风险自负。