oh-my-knowledge 0.51.1 → 0.51.2

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.
@@ -123,6 +123,8 @@ omk sample skills/my-skill/SKILL.md --focus "重点覆盖搜索失败 / 权限
123
123
  omk sample --batch
124
124
  ```
125
125
 
126
+ 目标执行器不支持工具拦截时,`omk sample` 会自动生成无 mock 用例。当前 `codex` / `codex-sdk` 属于这种情况;不要手工补 `mocks` 或 `mock_hit`。已有 mocks 用例会被 `omk eval` 在模型调用前拒绝,避免把执行器能力缺口误判成模型失败。`environment.files_available` 只提供题设上下文,不会在 `cwd` 物化文件。
127
+
126
128
  输出位置:目录 skill(`<skill>/SKILL.md`)→ `<skill>/.omk/samples.json`(标准);扁平 `.md` 单次生成 → 当前目录 `eval-samples.json`(项目级兜底);扁平 `.md` 的 `--batch` 兼容生成 `<skill-dir>/<name>.eval-samples.json`。
127
129
 
128
130
  ### 观测真实使用
@@ -602,7 +602,7 @@ omk sample [skillPath] [flags]
602
602
  - `--from-traces` `boolean`:from-traces 模式:从 observe inbox 的失败信号回流生成评测用例草稿(provenance: production-trace),落草稿待人工 review。
603
603
  - `--lang` `option` (默认 `zh`):输出语言 zh|en,优先级 CLI > OMK_LANG env > zh。
604
604
  - `--model` `option`:生成 LLM model 名。Codex 自动读取本机配置;也可用 OMK_MODEL 设置环境偏好。
605
- - `--no-mock` `boolean`:不生成 mocks,eval 时所有工具调用真实执行。
605
+ - `--no-mock` `boolean`:不生成 mocks。执行器不支持工具拦截时会自动启用,避免产生必然失败的 mock_hit。
606
606
  - `--observations-dir` `option`:observe inbox 目录(from-traces 模式用),默认项目 .omk/observe-inbox。
607
607
  - `--reports-dir` `option`:报告目录(fix 模式用),默认 ~/.oh-my-knowledge/reports。
608
608
  - `--skill` `option`:仅从指定 skill 的 observe inbox 信号生成草稿(仅 from-traces 模式用)。
@@ -689,7 +689,7 @@ omk studio --port 8080 --no-open
689
689
  | `construct` | 否 | 测的是什么构念 |
690
690
  | `provenance` | 否 | 用例来源(`omk sample` 自动打) |
691
691
  | `mocks` | 否 | 工具调用 mock 返回(sandbox 评测) |
692
- | `environment` | 否 | 评测环境前置「已就绪」声明 |
692
+ | `environment` | 否 | 题设环境声明(仅注入 prompt,不物化) |
693
693
  | `tripwire` | 否 | 标记为「故意诱错」样本,failed 时 diagnostic 不建议改 skill |
694
694
 
695
695
  完整 schema 见 [docs/specs/sample-design-spec.md](https://github.com/lizhiyao/oh-my-knowledge/blob/main/docs/specs/sample-design-spec.md)。
@@ -1,6 +1,7 @@
1
- import type { Sample, ExecutorFn } from '../types/index.js';
1
+ import type { ExecutorFn, Sample } from '../types/index.js';
2
2
  import type { ObservationInboxItem } from '../types/observability.js';
3
- interface GenerateSamplesOptions {
3
+ export declare function sampleGenerationUsesMocks(executorName: string | undefined, noMock?: boolean): boolean;
4
+ export interface GenerateSamplesOptions {
4
5
  skillContent: string;
5
6
  count?: number;
6
7
  model: string;
@@ -12,6 +13,8 @@ interface GenerateSamplesOptions {
12
13
  focus?: string;
13
14
  /** 不生成 mocks/mocksStrict,eval 时真实执行所有工具调用。 */
14
15
  noMock?: boolean;
16
+ /** Injectable executor for tests. Defaults to createExecutor(executorName). */
17
+ executor?: ExecutorFn;
15
18
  }
16
19
  /**
17
20
  * 拼出送给 LLM 的 user prompt。抽出来便于单测验证 focus 是否真的注入了。
@@ -26,7 +29,7 @@ export declare function buildSamplesPrompt({ skillContent, count, focus, noMock
26
29
  focus?: string;
27
30
  noMock?: boolean;
28
31
  }): string;
29
- export declare function generateSamples({ skillContent, count, model, executorName, focus, noMock }: GenerateSamplesOptions): Promise<{
32
+ export declare function generateSamples({ skillContent, count, model, executorName, focus, noMock, executor: injectedExecutor, }: GenerateSamplesOptions): Promise<{
30
33
  samples: Sample[];
31
34
  costUSD: number;
32
35
  }>;
@@ -62,12 +65,16 @@ export declare function stratifyTraceSignals(items: TraceSignalItem[]): Stratifi
62
65
  * production failures. The trace text feeds the *generator* only — never the
63
66
  * judge prompt — so judge-prompt isolation is unaffected.
64
67
  */
65
- export declare function buildSamplesFromTracesPrompt(items: TraceSignalItem[], count?: number): string;
68
+ export declare function buildSamplesFromTracesPrompt(items: TraceSignalItem[], count?: number, options?: {
69
+ noMock?: boolean;
70
+ }): string;
66
71
  export interface GenerateSamplesFromTracesOptions {
67
72
  items: TraceSignalItem[];
68
73
  count?: number;
69
74
  model: string;
70
75
  executorName?: string;
76
+ /** 不生成 mocks。目标 executor 不支持时会自动启用。 */
77
+ noMock?: boolean;
71
78
  /** Injectable executor (tests). Defaults to createExecutor(executorName). */
72
79
  executor?: ExecutorFn;
73
80
  }
@@ -77,12 +84,15 @@ export interface GenerateSamplesFromTracesOptions {
77
84
  * stamps `provenance: 'production-trace'`. Output is meant to land in a review draft,
78
85
  * not the live dataset (the CLI enforces that).
79
86
  */
80
- export declare function generateSamplesFromTraces({ items, count, model, executorName, executor: injectedExecutor, }: GenerateSamplesFromTracesOptions): Promise<{
87
+ export declare function generateSamplesFromTraces({ items, count, model, executorName, noMock, executor: injectedExecutor, }: GenerateSamplesFromTracesOptions): Promise<{
81
88
  samples: Sample[];
82
89
  costUSD: number;
83
90
  }>;
84
91
  export declare function sanitizeGeneratedSamples(samples: Sample[], opts?: {
85
92
  skillContent?: string;
93
+ mockless?: boolean;
94
+ migrateMocklessEnvironment?: boolean;
95
+ preserveMocklessEnvironment?: boolean;
86
96
  }): {
87
97
  stripped: string[];
88
98
  };
@@ -1,5 +1,7 @@
1
1
  import { createExecutor } from '../executors/index.js';
2
+ import { executorSupportsSampleMocks } from '../executors/capabilities.js';
2
3
  import { DEFAULT_GATE_THRESHOLD } from '../eval-core/verdict.js';
4
+ import { sampleMockReferenceKeys } from '../shared/sample-contract.js';
3
5
  const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据用户提供的 skill(系统提示词)内容,生成高质量的评测用例。
4
6
 
5
7
  样本结构决策(必须先做):先扫一遍 skill 内容判断它属于哪一类,按对应配比和数量生成。
@@ -195,14 +197,14 @@ const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据
195
197
  tool_input_not_contains 或 tools_not_called。
196
198
  - { "type": "regex", "pattern": "...", "weight": 1 }
197
199
  ↑ 同 contains 限制:只用在固定格式字面量(如 SHA / UUID / 路径模板)。
198
- - environment: 可选,对象。**评测环境的"已就绪"声明**,LLM 看到后跳过环境探测直接进工作流。
200
+ - environment: 可选,对象。**仅作 prompt 上下文的题设环境声明**,不会修改 PATH、创建文件或物化 fixture。
199
201
  字段:
200
202
  - cli_available: string[],已在 PATH 上的 CLI(如 ["node", "git", "code-host"])
201
203
  - files_available: string[],已存在的文件/脚本(如 ["~/.req-tool-api.json", "$SKILL_DIR/scripts/x.js"])
202
204
  - notes: string,自由文本兜底(如"DevAPI 凭证有效,工号 testuser001")
203
205
  原则:
204
- 凡是 skill 跑起来需要的环境(凭证文件 / 业务 CLI / 自带脚本 / API token 等),
205
- 都写到这里,而不是在 mock mock 它们的探测命令。这让 mock 只关注业务调用本身。
206
+ 只有用例明确把某项环境能力作为题设前提时才写。需要读取真实内容的文件不能放在
207
+ files_available 里冒充 fixture:应放进 sample.cwd 下的真实 fixture,或由 mock 返回内容。
206
208
  - mocksStrict: **必填且必须设为 true**(只要 sample 配了 mocks)。
207
209
  原因:mocksStrict=false 时,LLM 调到没匹配 mock 的命令会**透传到真 shell**,
208
210
  既可能真调外部接口产生副作用,也可能因二进制不存在(如 mcporter)报噪声错误污染评测信号。
@@ -223,8 +225,8 @@ const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据
223
225
  - 对(✅):写一条宽 mock:\`{tool:"Bash", match:{command_glob:"ls *"},
224
226
  return:{stdout:"<模拟目录列表>", exit:0}}\` — \`command_glob\` 用 \`*\` 兜底各种
225
227
  ls 参数变体(\`ls\` / \`ls -la\` / \`ls -d\` / \`ls /xx\` 全命中)
226
- (c) 单纯"已就绪"声明(凭证文件 / 业务 CLI 是否安装)还是走 \`environment\` 字段,
227
- 不需要 LLM 真调命令检查 — environment 字段就是告诉 LLM "这些不用检查"
228
+ (c) 题设明确声明可用的凭证 / CLI 可写进 \`environment\`,让 LLM 不做可用性探测。
229
+ 该字段只进 prompt,不会真的安装 CLI、创建凭证文件或改变 runtime
228
230
  (d) **intent-level mock(文件搜索/读取类操作)** — LLM 搜代码时会自由选择 Bash grep、
229
231
  Grep 工具、Glob+Read 组合、甚至 Agent 子代理,逐个枚举工具写 mock 不可持续。
230
232
  正确做法:用 \`tool: "*"\` + \`input_contains: "关键词"\` 按意图匹配:
@@ -367,6 +369,37 @@ const SYSTEM_PROMPT = `你是一个评测用例生成器。你的任务是根据
367
369
  - 字符串字段(prompt / rubric / capability 等)内部如需引号,**必须用全角「」**而不是半角 \`""\`,避免漏转义破坏 JSON 解析
368
370
  - 例:错 → \`"prompt": "查询"Daily"标签..."\`(内部 \`"\` 未转义,JSON 解析失败)
369
371
  对 → \`"prompt": "查询「Daily」标签..."\`(全角引号,无转义压力)`;
372
+ const MOCKLESS_SYSTEM_OVERRIDE = `
373
+
374
+ ## 目标执行器能力约束(最高优先级)
375
+
376
+ 目标执行器不支持可靠的工具调用拦截。本节覆盖上文所有要求 mocks、mock_hit、
377
+ files_available 或正向工具调用断言的规则:
378
+
379
+ - 不要输出 mocks、mocksStrict、environment 字段。
380
+ - 不要输出 mock_hit、tools_called、tools_count_min、tool_input_contains、tool_output_contains。
381
+ - 可以保留 tools_not_called、tools_count_max、tool_input_not_contains 这类负向安全约束。
382
+ - 把必要的输入事实直接写进 context,把可判分结果写进 rubric 或输出内容断言。
383
+ - 不要假装某个文件、CLI 或工具调用已经由 fixture 物化。`;
384
+ function generationSystemPrompt(mockless) {
385
+ return mockless ? `${SYSTEM_PROMPT}${MOCKLESS_SYSTEM_OVERRIDE}` : SYSTEM_PROMPT;
386
+ }
387
+ const warnedAutoMocklessExecutors = new Set();
388
+ function warnAutoMockless(executorName, noMock) {
389
+ if (noMock
390
+ || !executorName
391
+ || executorSupportsSampleMocks(executorName)
392
+ || warnedAutoMocklessExecutors.has(executorName))
393
+ return;
394
+ warnedAutoMocklessExecutors.add(executorName);
395
+ process.stderr.write(`[omk sample] 执行器「${executorName}」不支持工具调用拦截,`
396
+ + '已自动切换为无 mocks 用例。\n');
397
+ }
398
+ export function sampleGenerationUsesMocks(executorName, noMock = false) {
399
+ if (noMock)
400
+ return false;
401
+ return executorName ? executorSupportsSampleMocks(executorName) : true;
402
+ }
370
403
  /**
371
404
  * 拼出送给 LLM 的 user prompt。抽出来便于单测验证 focus 是否真的注入了。
372
405
  *
@@ -390,9 +423,12 @@ ${skillContent}
390
423
 
391
424
  ${countLine}直接输出 JSON 数组。${focusBlock}${noMockBlock}`;
392
425
  }
393
- export async function generateSamples({ skillContent, count, model, executorName, focus, noMock }) {
394
- const executor = createExecutor(executorName);
395
- const prompt = buildSamplesPrompt({ skillContent, count, focus, noMock });
426
+ export async function generateSamples({ skillContent, count, model, executorName, focus, noMock, executor: injectedExecutor, }) {
427
+ const executor = injectedExecutor ?? createExecutor(executorName);
428
+ const mockless = !sampleGenerationUsesMocks(executorName, noMock);
429
+ warnAutoMockless(executorName, noMock);
430
+ const prompt = buildSamplesPrompt({ skillContent, count, focus, noMock: mockless });
431
+ const system = generationSystemPrompt(mockless);
396
432
  // 生成场景比单次 eval 调用更重(LLM 要思考结构 + 输出大段 JSON),
397
433
  // 默认 120s 对长 skill + count >= 8 经常不够,这里用 5 分钟兜底。
398
434
  // lean=true 关掉 agent 工具循环 / skill 发现 — 生成只需要纯文本,不需要 Bash / Read 等工具。
@@ -405,7 +441,7 @@ export async function generateSamples({ skillContent, count, model, executorName
405
441
  const attemptPrompt = attempt === 1
406
442
  ? prompt
407
443
  : `${prompt}\n\n上一次输出解析失败:${lastErr}\n请严格按 JSON 规范输出(字符串内部用「」全角引号),只输出数组,不要包含其他文字。`;
408
- const result = await executor({ model, system: SYSTEM_PROMPT, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
444
+ const result = await executor({ model, system, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
409
445
  totalCost += result.costUSD || 0;
410
446
  if (!result.ok) {
411
447
  lastErr = result.error || 'unknown error';
@@ -435,12 +471,15 @@ export async function generateSamples({ skillContent, count, model, executorName
435
471
  continue;
436
472
  }
437
473
  // 通过校验,跳出循环继续后续 sanitize
438
- return await finalizeSamples(samples, totalCost, skillContent);
474
+ return await finalizeSamples(samples, totalCost, {
475
+ skillContent,
476
+ mockless,
477
+ });
439
478
  }
440
479
  // 不可达 (循环里所有出口都 throw 或 return),保留是为了 TS 类型推断
441
480
  throw new Error('unreachable');
442
481
  }
443
- async function finalizeSamples(samples, costUSD, skillContent) {
482
+ async function finalizeSamples(samples, costUSD, options) {
444
483
  // Validate required fields + sanitize metadata enums *at generator boundary*
445
484
  // (see sanitizeGeneratedSamples). skillContent is passed so the function can
446
485
  // strip "脑补"-style fact assertions whose tool name has no literal mention
@@ -448,7 +487,10 @@ async function finalizeSamples(samples, costUSD, skillContent) {
448
487
  // the data-security-review v1-v5 regen series (generator kept producing
449
488
  // tool_input_contains "WebFetch:语雀URL" even after 7 prompt iterations,
450
489
  // because LLM's "URL → fetch" training prior overrides instructional text).
451
- const { stripped } = sanitizeGeneratedSamples(samples, { skillContent });
490
+ const { stripped } = sanitizeGeneratedSamples(samples, {
491
+ ...options,
492
+ migrateMocklessEnvironment: true,
493
+ });
452
494
  if (stripped.length > 0) {
453
495
  process.stderr.write(`[omk sample] LLM-output 含 ${stripped.length} 个非法元数据/断言字段,已剥离避免污染:\n - ${stripped.join('\n - ')}\n`);
454
496
  }
@@ -492,7 +534,7 @@ export function stratifyTraceSignals(items) {
492
534
  * production failures. The trace text feeds the *generator* only — never the
493
535
  * judge prompt — so judge-prompt isolation is unaffected.
494
536
  */
495
- export function buildSamplesFromTracesPrompt(items, count) {
537
+ export function buildSamplesFromTracesPrompt(items, count, options = {}) {
496
538
  // 先按频次分层(合并重复 + 算占比 + 降序),让模型按「占比」分配配额,而非每信号一刀切。
497
539
  const stratified = stratifyTraceSignals(items);
498
540
  const sections = stratified.map((it, i) => {
@@ -515,13 +557,16 @@ export function buildSamplesFromTracesPrompt(items, count) {
515
557
  const countLine = typeof count === 'number'
516
558
  ? `共生成约 ${count} 条评测用例,按各信号的「占比」分配配额(高频多、低频少),覆盖整体失败分布。`
517
559
  : '按各信号「占比」分配:高频信号多生成、低频少生成,覆盖整体失败分布。';
560
+ const mocklessBlock = options.noMock
561
+ ? '\n\n目标执行器不支持工具调用拦截:不要生成 mocks、mocksStrict、environment 或正向工具调用断言;把 trace 证据写入 context 和 rubric。'
562
+ : '';
518
563
  return `${TRACE_GEN_INSTRUCTIONS}
519
564
 
520
565
  ## 观测到的失败信号(共 ${stratified.length} 个,已按出现频次降序)
521
566
 
522
567
  ${sections}
523
568
 
524
- ${countLine}直接输出 JSON 数组。`;
569
+ ${countLine}直接输出 JSON 数组。${mocklessBlock}`;
525
570
  }
526
571
  /** Build a sanitize context string from trace evidence so finalizeSamples keeps
527
572
  * tool-name assertions that reference tools actually seen in the traces (an empty
@@ -539,14 +584,17 @@ function traceSanitizeContext(items) {
539
584
  * stamps `provenance: 'production-trace'`. Output is meant to land in a review draft,
540
585
  * not the live dataset (the CLI enforces that).
541
586
  */
542
- export async function generateSamplesFromTraces({ items, count, model, executorName, executor: injectedExecutor, }) {
587
+ export async function generateSamplesFromTraces({ items, count, model, executorName, noMock, executor: injectedExecutor, }) {
543
588
  if (items.length === 0)
544
589
  return { samples: [], costUSD: 0 };
545
590
  if (!injectedExecutor && !executorName) {
546
591
  throw new Error('executorName is required when no executor is injected');
547
592
  }
548
593
  const executor = injectedExecutor ?? createExecutor(executorName);
549
- const prompt = buildSamplesFromTracesPrompt(items, count);
594
+ const mockless = !sampleGenerationUsesMocks(executorName, noMock);
595
+ warnAutoMockless(executorName, noMock);
596
+ const prompt = buildSamplesFromTracesPrompt(items, count, { noMock: mockless });
597
+ const system = generationSystemPrompt(mockless);
550
598
  const sanitizeContext = traceSanitizeContext(items);
551
599
  const PROVENANCE = 'production-trace';
552
600
  const MAX_ATTEMPTS = 3;
@@ -556,7 +604,7 @@ export async function generateSamplesFromTraces({ items, count, model, executorN
556
604
  const attemptPrompt = attempt === 1
557
605
  ? prompt
558
606
  : `${prompt}\n\n上一次输出解析失败:${lastErr}\n请严格按 JSON 规范输出(字符串内部用「」全角引号),只输出数组,不要包含其他文字。`;
559
- const result = await executor({ model, system: SYSTEM_PROMPT, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
607
+ const result = await executor({ model, system, prompt: attemptPrompt, timeoutMs: 300_000, lean: true });
560
608
  totalCost += result.costUSD || 0;
561
609
  if (!result.ok) {
562
610
  lastErr = result.error || 'unknown error';
@@ -593,7 +641,10 @@ export async function generateSamplesFromTraces({ items, count, model, executorN
593
641
  // Stamp provenance before sanitize so it survives (it's a valid enum value).
594
642
  for (const s of samples)
595
643
  s.provenance = PROVENANCE;
596
- return await finalizeSamples(samples, totalCost, sanitizeContext);
644
+ return await finalizeSamples(samples, totalCost, {
645
+ skillContent: sanitizeContext,
646
+ mockless,
647
+ });
597
648
  }
598
649
  throw new Error('unreachable');
599
650
  }
@@ -656,6 +707,78 @@ const TEXT_VALUE_TYPES = new Set([
656
707
  const TOOL_POSITIVE_TYPES = new Set([
657
708
  'tool_input_contains', 'tool_output_contains', 'mock_hit',
658
709
  ]);
710
+ const MOCKLESS_POSITIVE_TOOL_TYPES = new Set([
711
+ 'mock_hit',
712
+ 'tools_called',
713
+ 'tools_count_min',
714
+ 'tool_input_contains',
715
+ 'tool_output_contains',
716
+ ]);
717
+ function stripMocklessPositiveToolAssertions(assertions, label, stripped) {
718
+ const kept = [];
719
+ for (const [index, assertion] of assertions.entries()) {
720
+ const assertionLabel = `${label}[${index}]`;
721
+ if (assertion?.type === 'assert-set' && Array.isArray(assertion.children)) {
722
+ const children = stripMocklessPositiveToolAssertions(assertion.children, `${assertionLabel}.children`, stripped);
723
+ if (children.length === 0) {
724
+ stripped.push(`${assertionLabel}.assert-set(无 mock 模式下没有可执行子断言)`);
725
+ continue;
726
+ }
727
+ assertion.children = children;
728
+ kept.push(assertion);
729
+ continue;
730
+ }
731
+ if (MOCKLESS_POSITIVE_TOOL_TYPES.has(assertion?.type)) {
732
+ stripped.push(`${assertionLabel}.${assertion.type}(目标执行器不支持 mocks,正向工具证据不可复现)`);
733
+ continue;
734
+ }
735
+ kept.push(assertion);
736
+ }
737
+ return kept;
738
+ }
739
+ function stripInvalidMockHitAssertions(assertions, mockKeys, label, stripped) {
740
+ const kept = [];
741
+ for (const [index, assertion] of assertions.entries()) {
742
+ const assertionLabel = `${label}[${index}]`;
743
+ if (assertion?.type === 'assert-set' && Array.isArray(assertion.children)) {
744
+ const children = stripInvalidMockHitAssertions(assertion.children, mockKeys, `${assertionLabel}.children`, stripped);
745
+ if (children.length === 0) {
746
+ stripped.push(`${assertionLabel}.assert-set(没有可执行子断言)`);
747
+ continue;
748
+ }
749
+ assertion.children = children;
750
+ kept.push(assertion);
751
+ continue;
752
+ }
753
+ if (assertion?.type === 'mock_hit'
754
+ && typeof assertion.value === 'string'
755
+ && !mockKeys.has(assertion.value)) {
756
+ stripped.push(`${assertionLabel}.mock_hit(未引用实际 mock:${assertion.value})`);
757
+ continue;
758
+ }
759
+ kept.push(assertion);
760
+ }
761
+ return kept;
762
+ }
763
+ function environmentAsPromptContext(environment) {
764
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)) {
765
+ return null;
766
+ }
767
+ const env = environment;
768
+ const lines = ['题设环境声明(仅作上下文,不会在 cwd 物化):'];
769
+ if (Array.isArray(env.cli_available)
770
+ && env.cli_available.every((item) => typeof item === 'string' && item.length > 0)) {
771
+ lines.push(`- 可用 CLI:${env.cli_available.join('、')}`);
772
+ }
773
+ if (Array.isArray(env.files_available)
774
+ && env.files_available.every((item) => typeof item === 'string' && item.length > 0)) {
775
+ lines.push(`- 题设引用路径(未物化):${env.files_available.join('、')}`);
776
+ }
777
+ if (typeof env.notes === 'string' && env.notes.trim()) {
778
+ lines.push(`- 说明:${env.notes.trim()}`);
779
+ }
780
+ return lines.length > 1 ? lines.join('\n') : null;
781
+ }
659
782
  function isAsciiTokenLike(v) {
660
783
  if (typeof v !== 'string')
661
784
  return false;
@@ -719,6 +842,40 @@ export function sanitizeGeneratedSamples(samples, opts = {}) {
719
842
  stripped.push(`samples[${i}].tripwire (${typeof s.tripwire})`);
720
843
  delete s.tripwire;
721
844
  }
845
+ if (opts.mockless) {
846
+ if (Array.isArray(s.mocks) && s.mocks.length > 0) {
847
+ stripped.push(`samples[${i}].mocks(目标执行器不支持工具调用拦截)`);
848
+ }
849
+ delete s.mocks;
850
+ if (s.mocksStrict !== undefined) {
851
+ stripped.push(`samples[${i}].mocksStrict(无 mocks)`);
852
+ }
853
+ delete s.mocksStrict;
854
+ if (s.environment !== undefined && !opts.preserveMocklessEnvironment) {
855
+ const environmentContext = opts.migrateMocklessEnvironment
856
+ ? environmentAsPromptContext(s.environment)
857
+ : null;
858
+ if (environmentContext) {
859
+ const existingContext = typeof s.context === 'string' ? s.context.trim() : '';
860
+ s.context = existingContext
861
+ ? `${existingContext}\n\n${environmentContext}`
862
+ : environmentContext;
863
+ stripped.push(`samples[${i}].environment(已迁移到题设 context,未物化)`);
864
+ }
865
+ else {
866
+ stripped.push(`samples[${i}].environment(未物化的环境声明不能充当 fixture)`);
867
+ }
868
+ }
869
+ if (!opts.preserveMocklessEnvironment) {
870
+ delete s.environment;
871
+ }
872
+ if (Array.isArray(s.assertions)) {
873
+ s.assertions = stripMocklessPositiveToolAssertions(s.assertions, `samples[${i}].assertions`, stripped);
874
+ if (s.assertions.length === 0) {
875
+ delete s.assertions;
876
+ }
877
+ }
878
+ }
722
879
  // assertions 校验:loader 会拒掉两类无效断言 — 在 generator boundary 提前 strip,
723
880
  // 避免落盘的 sample 跑不动:
724
881
  // 1. tools_called / tools_not_called 的 values 必须非空
@@ -843,6 +1000,12 @@ export function sanitizeGeneratedSamples(samples, opts = {}) {
843
1000
  delete s.mocks;
844
1001
  }
845
1002
  }
1003
+ if (Array.isArray(s.assertions)) {
1004
+ s.assertions = stripInvalidMockHitAssertions(s.assertions, sampleMockReferenceKeys(s.mocks), `samples[${i}].assertions`, stripped);
1005
+ if (s.assertions.length === 0) {
1006
+ delete s.assertions;
1007
+ }
1008
+ }
846
1009
  // mocksStrict 兜底:有 mocks 时强制 true。
847
1010
  // SYSTEM_PROMPT 已要求 LLM 必填,但偶尔 LLM 漏填 — 在 generator boundary 修掉,
848
1011
  // 避免运行时 mock 未命中透传到真 shell(报 mcporter not found 等噪声错误)。
@@ -34,6 +34,8 @@ export interface FixSamplesOptions {
34
34
  }>;
35
35
  model: string;
36
36
  maxAttemptsPerSample?: number;
37
+ /** Target executor cannot intercept tool calls; strip mocks and dependent positive evidence. */
38
+ mockless?: boolean;
37
39
  }
38
40
  export interface FixSamplesResult {
39
41
  samples: Record<string, unknown>[];
@@ -20,8 +20,17 @@ const FIX_SYSTEM_PROMPT = `你是一个评测用例修复专家。根据诊断
20
20
  输出一个 **JSON 数组**,包含所有待修复 sample(修改过的和原样保留的都要包含)。
21
21
  第一字符 \`[\`,最后 \`]\`,不要用 \`\`\`json\`\`\` 围栏,不要寒暄。
22
22
  如果判断某条是 LLM 行为问题不需要改,也原样放进数组。`;
23
- function sanitizeFixedSamples(samples, skillContent) {
24
- sanitizeGeneratedSamples(samples, { skillContent });
23
+ const MOCKLESS_FIX_OVERRIDE = `
24
+
25
+ 目标执行器不支持工具调用拦截。不得新增或保留 mocks、mocksStrict、
26
+ mock_hit、tools_called、tools_count_min、tool_input_contains、tool_output_contains。
27
+ environment 仅表示题设上下文,不得当作已物化 fixture;负向安全断言可以保留。`;
28
+ function sanitizeFixedSamples(samples, skillContent, mockless) {
29
+ sanitizeGeneratedSamples(samples, {
30
+ skillContent,
31
+ mockless,
32
+ preserveMocklessEnvironment: true,
33
+ });
25
34
  return samples;
26
35
  }
27
36
  const FIXABLE_SAMPLE_FIELDS = new Set([
@@ -97,7 +106,7 @@ function parseFixedSamples(text) {
97
106
  return null;
98
107
  }
99
108
  export async function fixSamples(options) {
100
- const { skillContent, samples, report, treatmentKey, executor, model, maxAttemptsPerSample = 2 } = options;
109
+ const { skillContent, samples, report, treatmentKey, executor, model, maxAttemptsPerSample = 2, mockless = false, } = options;
101
110
  const sampleMap = new Map(samples.map((s) => [s.sample_id, s]));
102
111
  // Collect all fixable samples into one batch
103
112
  const fixContexts = [];
@@ -185,7 +194,12 @@ ${sampleSections}
185
194
  let incurredCostUSD = 0;
186
195
  let incurredCostReported = false;
187
196
  try {
188
- const result = await executor({ model, system: FIX_SYSTEM_PROMPT, prompt, timeoutMs: 300_000 });
197
+ const result = await executor({
198
+ model,
199
+ system: mockless ? `${FIX_SYSTEM_PROMPT}${MOCKLESS_FIX_OVERRIDE}` : FIX_SYSTEM_PROMPT,
200
+ prompt,
201
+ timeoutMs: 300_000,
202
+ });
189
203
  incurredCostUSD = result.costUSD;
190
204
  incurredCostReported = result.costReported !== false;
191
205
  if (!result.ok) {
@@ -226,7 +240,7 @@ ${sampleSections}
226
240
  })),
227
241
  };
228
242
  }
229
- const sanitizedFixedArr = sanitizeFixedSamples(fixedArr, skillContent);
243
+ const sanitizedFixedArr = sanitizeFixedSamples(fixedArr, skillContent, mockless);
230
244
  const outOfScope = sanitizedFixedArr.find((fixed) => {
231
245
  const sid = fixed.sample_id;
232
246
  const original = sampleMap.get(sid);
@@ -274,7 +274,7 @@ async function runSampleFix(args, flags, lang) {
274
274
  throw new CliExit(1);
275
275
  }
276
276
  process.stderr.write(lang === 'zh' ? `🔧 发现 ${sampleDesignCount} 条 sample_design 失败,开始修复...\n` : `🔧 Found ${sampleDesignCount} sample_design failure(s), fixing...\n`);
277
- const { createExecutor } = await import('../../executors/index.js');
277
+ const { createExecutor, executorSupportsSampleMocks, } = await import('../../executors/index.js');
278
278
  const exec = createExecutor(executorName);
279
279
  const executorFn = async (opts) => {
280
280
  const result = await exec({
@@ -298,6 +298,7 @@ async function runSampleFix(args, flags, lang) {
298
298
  treatmentKey: treatmentName,
299
299
  executor: executorFn,
300
300
  model,
301
+ mockless: !executorSupportsSampleMocks(executorName),
301
302
  });
302
303
  let writtenFiles = [];
303
304
  if (result.fixedCount > 0) {
@@ -363,7 +364,13 @@ export async function runSampleFromTraces(flags, lang) {
363
364
  ? `🔭 发现 ${items.length} 个${flags.skill ? ` ${flags.skill} 的` : ''}失败信号,正在生成评测用例草稿...\n`
364
365
  : `🔭 Found ${items.length}${flags.skill ? ` ${flags.skill}` : ''} failure signal(s); generating regression-sample drafts...\n`);
365
366
  try {
366
- const { samples, costUSD } = await generateSamplesFromTraces({ items, count, model, executorName });
367
+ const { samples, costUSD } = await generateSamplesFromTraces({
368
+ items,
369
+ count,
370
+ model,
371
+ executorName,
372
+ noMock: flags['no-mock'],
373
+ });
367
374
  const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
368
375
  if (samples.length === 0) {
369
376
  // The model conservatively skipped every signal (noise / unreproducible). That's a
@@ -663,8 +670,8 @@ export default class Sample extends BaseCommand {
663
670
  }),
664
671
  'no-mock': Flags.boolean({
665
672
  description: bilingual({
666
- zh: '不生成 mocks,eval 时所有工具调用真实执行。',
667
- en: 'Skip mock generation; all tool calls execute for real during eval.',
673
+ zh: '不生成 mocks。执行器不支持工具拦截时会自动启用,避免产生必然失败的 mock_hit。',
674
+ en: 'Skip mocks. Automatically enabled when the executor cannot intercept tools, preventing impossible mock_hit assertions.',
668
675
  }),
669
676
  default: false,
670
677
  }),
@@ -258,8 +258,17 @@ export async function executeTasks({ tasks, executor, executorName, model, noJud
258
258
  }
259
259
  }
260
260
  let factCheck;
261
- if (execResult.ok && execResult.output && task.cwd) {
262
- factCheck = checkFacts(execResult.output, resolve(task.cwd));
261
+ if (execResult.ok && execResult.output) {
262
+ const sharedEvidence = {
263
+ ...(task._sample.context && { context: task._sample.context }),
264
+ ...(task._sample.environment?.files_available?.length && {
265
+ declaredFiles: task._sample.environment.files_available,
266
+ }),
267
+ // Resolve exactly like the executor's cwd. samplesBaseDir is for bundle
268
+ // assets such as mocks, not for changing Sample.cwd path semantics.
269
+ ...(task._sample.cwd && { cwd: resolve(task._sample.cwd) }),
270
+ };
271
+ factCheck = checkFacts(execResult.output, sharedEvidence);
263
272
  }
264
273
  const sampleResults = ownRecordValue(results, task.sample_id)
265
274
  ?? setOwnRecordValue(results, task.sample_id, {});
@@ -14,11 +14,24 @@ export interface FactCheckResult {
14
14
  totalCount: number;
15
15
  verifiedRate: number;
16
16
  }
17
+ export interface FactCheckEvidence {
18
+ /** Shared sample fixture root. Arm-specific execution directories must not be used. */
19
+ cwd?: string;
20
+ /** Facts supplied to both arms in the sample context. */
21
+ context?: string;
22
+ /** Declarative fixture paths from sample.environment.files_available. */
23
+ declaredFiles?: string[];
24
+ }
17
25
  /**
18
26
  * Extract file path claims from agent output text.
19
27
  */
20
28
  export declare function extractPathClaims(output: string): string[];
21
29
  /**
22
- * Check facts in agent output by verifying file paths exist in cwd.
30
+ * Check file-path facts against sample-level evidence shared by every arm.
31
+ *
32
+ * A string keeps the legacy direct-filesystem API for callers outside the
33
+ * evaluation pipeline. The pipeline passes structured evidence and deliberately
34
+ * excludes each artifact's execution cwd, because that directory is not a
35
+ * comparable source of truth across control and treatment arms.
23
36
  */
24
- export declare function checkFacts(output: string, cwd: string): FactCheckResult;
37
+ export declare function checkFacts(output: string, evidence: string | FactCheckEvidence): FactCheckResult;
@@ -22,6 +22,10 @@ const IGNORE_PATTERNS = [
22
22
  /^\.git\//,
23
23
  /^index\.\w+$/,
24
24
  ];
25
+ // Product/runtime names that happen to end in a supported source extension.
26
+ const NON_PATH_REFERENCES = new Set([
27
+ 'node.js',
28
+ ]);
25
29
  /**
26
30
  * Extract file path claims from agent output text.
27
31
  */
@@ -36,35 +40,86 @@ export function extractPathClaims(output) {
36
40
  path = path.replace(/[.,;:!?))]+$/, '');
37
41
  // Clean trailing backtick/quote
38
42
  path = path.replace(/[`'"]+$/, '');
39
- if (path.length > 3 && !IGNORE_PATTERNS.some((p) => p.test(path))) {
43
+ if (path.length > 3
44
+ && !NON_PATH_REFERENCES.has(path.toLowerCase())
45
+ && !IGNORE_PATTERNS.some((p) => p.test(path))) {
40
46
  paths.add(path);
41
47
  }
42
48
  }
43
49
  }
44
50
  return [...paths];
45
51
  }
52
+ function normalizeEvidencePath(path) {
53
+ return path
54
+ .trim()
55
+ .replace(/^['"`]+|['"`]+$/g, '')
56
+ .replace(/\\/g, '/')
57
+ .replace(/^(?:\$SKILL_DIR|~)\//, '')
58
+ .replace(/^\.\//, '')
59
+ .replace(/\/+$/, '');
60
+ }
61
+ function isRootedEvidencePath(path) {
62
+ return path.startsWith('/') || /^[a-zA-Z]:\//.test(path);
63
+ }
64
+ function refersToSamePath(left, right) {
65
+ const a = normalizeEvidencePath(left);
66
+ const b = normalizeEvidencePath(right);
67
+ return a === b
68
+ || (isRootedEvidencePath(a) && !isRootedEvidencePath(b) && a.endsWith(`/${b}`))
69
+ || (isRootedEvidencePath(b) && !isRootedEvidencePath(a) && b.endsWith(`/${a}`));
70
+ }
46
71
  /**
47
- * Check facts in agent output by verifying file paths exist in cwd.
72
+ * Check file-path facts against sample-level evidence shared by every arm.
73
+ *
74
+ * A string keeps the legacy direct-filesystem API for callers outside the
75
+ * evaluation pipeline. The pipeline passes structured evidence and deliberately
76
+ * excludes each artifact's execution cwd, because that directory is not a
77
+ * comparable source of truth across control and treatment arms.
48
78
  */
49
- export function checkFacts(output, cwd) {
79
+ export function checkFacts(output, evidence) {
50
80
  const pathClaims = extractPathClaims(output);
51
- const root = resolve(cwd);
52
- const claims = pathClaims.map((path) => {
53
- const fullPath = resolve(root, path);
54
- const relativePath = relative(root, fullPath);
55
- const insideCwd = relativePath === ''
56
- || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
57
- const exists = insideCwd && existsSync(fullPath);
58
- return {
59
- type: 'file-path',
60
- value: path,
61
- verified: exists,
62
- ...(!exists && {
63
- evidence: insideCwd
64
- ? `${fullPath} not found`
65
- : `${path} is outside the evaluation cwd`,
66
- }),
67
- };
81
+ const sources = typeof evidence === 'string'
82
+ ? { cwd: evidence }
83
+ : evidence;
84
+ const root = sources.cwd ? resolve(sources.cwd) : null;
85
+ const contextClaims = sources.context
86
+ ? extractPathClaims(sources.context)
87
+ : [];
88
+ const declaredFiles = sources.declaredFiles ?? [];
89
+ const claims = pathClaims.flatMap((path) => {
90
+ if (declaredFiles.some((declared) => refersToSamePath(path, declared))) {
91
+ return [{
92
+ type: 'file-path',
93
+ value: path,
94
+ verified: true,
95
+ evidence: 'source=context(sample.environment.files_available)',
96
+ }];
97
+ }
98
+ if (root) {
99
+ const fullPath = resolve(root, path);
100
+ const relativePath = relative(root, fullPath);
101
+ const insideCwd = relativePath === ''
102
+ || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
103
+ const exists = insideCwd && existsSync(fullPath);
104
+ return [{
105
+ type: 'file-path',
106
+ value: path,
107
+ verified: exists,
108
+ evidence: insideCwd
109
+ ? `source=runtime-filesystem; ${fullPath} ${exists ? 'exists' : 'not found'}`
110
+ : `source=runtime-filesystem; ${path} is outside the evaluation cwd`,
111
+ }];
112
+ }
113
+ if (contextClaims.some((contextPath) => refersToSamePath(path, contextPath))) {
114
+ return [{
115
+ type: 'file-path',
116
+ value: path,
117
+ verified: true,
118
+ evidence: 'source=context(sample.context)',
119
+ }];
120
+ }
121
+ // Without a shared fixture, absence from context is unknown rather than false.
122
+ return [];
68
123
  });
69
124
  const verifiedCount = claims.filter((c) => c.verified).length;
70
125
  const totalCount = claims.length;
@@ -92,8 +92,47 @@ function anyStringContains(obj, needle) {
92
92
  return false;
93
93
  }
94
94
 
95
+ const BUILTIN_TOOL_ALIASES = {
96
+ bash: 'Bash',
97
+ shell: 'Bash',
98
+ exec_command: 'Bash',
99
+ command_execution: 'Bash',
100
+ read: 'Read',
101
+ file_read: 'Read',
102
+ grep: 'Grep',
103
+ edit: 'Edit',
104
+ apply_patch: 'Edit',
105
+ file_change: 'Edit',
106
+ write: 'Write',
107
+ file_write: 'Write',
108
+ view_image: 'ViewImage',
109
+ viewimage: 'ViewImage',
110
+ write_stdin: 'WriteStdin',
111
+ writestdin: 'WriteStdin',
112
+ web_search: 'WebSearch',
113
+ websearch: 'WebSearch',
114
+ };
115
+
116
+ function canonicalToolName(name) {
117
+ const sourceName = String(name);
118
+ const builtin = BUILTIN_TOOL_ALIASES[sourceName.toLowerCase()];
119
+ if (builtin) return builtin;
120
+ const parts = sourceName.split('__').filter(Boolean);
121
+ if (parts[0] === 'mcp' && parts.length > 2) {
122
+ const providerParts = parts.slice(1, -1);
123
+ if (providerParts[0] === 'codex_apps' && providerParts.length > 1) providerParts.shift();
124
+ return providerParts.join('.') + '.' + parts[parts.length - 1];
125
+ }
126
+ return sourceName;
127
+ }
128
+
129
+ function toolIdentityMatches(expectedName, runtimeName) {
130
+ return expectedName === runtimeName
131
+ || canonicalToolName(expectedName) === canonicalToolName(runtimeName);
132
+ }
133
+
95
134
  function isMockHit(mock, toolName, toolInput) {
96
- if (mock.tool !== '*' && mock.tool !== toolName) return false;
135
+ if (mock.tool !== '*' && !toolIdentityMatches(mock.tool, toolName)) return false;
97
136
  const m = mock.match;
98
137
  if (!m) return true;
99
138
  const ti = toolInput || {};
@@ -18,6 +18,7 @@ import { homedir, tmpdir } from 'node:os';
18
18
  import { dirname, isAbsolute, join, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { incrementRecordCount, setOwnRecordValue } from '../shared/record-count.js';
21
+ import { toolIdentityMatches } from '../shared/tool-identity.js';
21
22
  // ─── Match logic ────────────────────────────────────────────────────────────
22
23
  function expandHome(p) {
23
24
  if (p.startsWith('~/'))
@@ -112,7 +113,7 @@ function anyStringContains(obj, needle) {
112
113
  }
113
114
  /** 单条 mock 是否命中给定 tool 调用。 */
114
115
  export function isMockHit(mock, toolName, toolInput) {
115
- if (mock.tool !== '*' && mock.tool !== toolName)
116
+ if (mock.tool !== '*' && !toolIdentityMatches(mock.tool, toolName))
116
117
  return false;
117
118
  const m = mock.match;
118
119
  if (!m)
@@ -414,8 +415,44 @@ function anyStringContains(obj, needle) {
414
415
  if (typeof obj === 'object' && obj !== null) return Object.values(obj).some((v) => anyStringContains(v, needle));
415
416
  return false;
416
417
  }
418
+ const BUILTIN_TOOL_ALIASES = {
419
+ bash: 'Bash',
420
+ shell: 'Bash',
421
+ exec_command: 'Bash',
422
+ command_execution: 'Bash',
423
+ read: 'Read',
424
+ file_read: 'Read',
425
+ grep: 'Grep',
426
+ edit: 'Edit',
427
+ apply_patch: 'Edit',
428
+ file_change: 'Edit',
429
+ write: 'Write',
430
+ file_write: 'Write',
431
+ view_image: 'ViewImage',
432
+ viewimage: 'ViewImage',
433
+ write_stdin: 'WriteStdin',
434
+ writestdin: 'WriteStdin',
435
+ web_search: 'WebSearch',
436
+ websearch: 'WebSearch',
437
+ };
438
+ function canonicalToolName(name) {
439
+ const sourceName = String(name);
440
+ const builtin = BUILTIN_TOOL_ALIASES[sourceName.toLowerCase()];
441
+ if (builtin) return builtin;
442
+ const parts = sourceName.split('__').filter(Boolean);
443
+ if (parts[0] === 'mcp' && parts.length > 2) {
444
+ const providerParts = parts.slice(1, -1);
445
+ if (providerParts[0] === 'codex_apps' && providerParts.length > 1) providerParts.shift();
446
+ return providerParts.join('.') + '.' + parts[parts.length - 1];
447
+ }
448
+ return sourceName;
449
+ }
450
+ function toolIdentityMatches(expectedName, runtimeName) {
451
+ return expectedName === runtimeName
452
+ || canonicalToolName(expectedName) === canonicalToolName(runtimeName);
453
+ }
417
454
  function isMockHit(mock, toolName, toolInput) {
418
- if (mock.tool !== '*' && mock.tool !== toolName) return false;
455
+ if (mock.tool !== '*' && !toolIdentityMatches(mock.tool, toolName)) return false;
419
456
  const m = mock.match;
420
457
  if (!m) return true;
421
458
  const ti = toolInput || {};
@@ -1,8 +1,8 @@
1
1
  import type { Artifact, Sample, SampleEnvironment, Task } from '../types/index.js';
2
2
  /**
3
3
  * 把 sample.environment 渲染成自然语言段落,放在用户 prompt 前。
4
- * LLM 读到"环境已就绪",跳过 Glob / find / which / Read 这些环境探测,
5
- * 直接进入 skill 描述的工作流 评测信号纯,mock 设计也简化(不用 mock 探测命令)。
4
+ * 这是题设上下文,不会修改 PATH、物化文件或改变 runtime;LLM 可据此跳过
5
+ * which / test -f 等可用性探测,直接进入 skill 描述的工作流。
6
6
  *
7
7
  * 输出 null 表示 sample 没声明 environment,prompt 不变。
8
8
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 把 sample.environment 渲染成自然语言段落,放在用户 prompt 前。
3
- * LLM 读到"环境已就绪",跳过 Glob / find / which / Read 这些环境探测,
4
- * 直接进入 skill 描述的工作流 评测信号纯,mock 设计也简化(不用 mock 探测命令)。
3
+ * 这是题设上下文,不会修改 PATH、物化文件或改变 runtime;LLM 可据此跳过
4
+ * which / test -f 等可用性探测,直接进入 skill 描述的工作流。
5
5
  *
6
6
  * 输出 null 表示 sample 没声明 environment,prompt 不变。
7
7
  */
@@ -10,26 +10,26 @@ export function renderEnvironmentSection(env) {
10
10
  return null;
11
11
  const lines = [];
12
12
  if (env.cli_available && env.cli_available.length > 0) {
13
- lines.push('- 已安装 CLI(已在 PATH,无需 `which` / `command -v` / `type` 探测):');
13
+ lines.push('- 题设声明可用的 CLI(仅作上下文,不修改 PATH):');
14
14
  for (const c of env.cli_available)
15
15
  lines.push(` - \`${c}\``);
16
16
  }
17
17
  if (env.files_available && env.files_available.length > 0) {
18
- lines.push('- 已存在文件(无需 Glob / `Read` / `test -f` 探测):');
18
+ lines.push('- 题设引用的文件路径(仅作上下文,不会在 cwd 物化):');
19
19
  for (const f of env.files_available)
20
20
  lines.push(` - \`${f}\``);
21
21
  }
22
22
  if (env.notes && env.notes.trim()) {
23
- lines.push(`- 备注:${env.notes.trim()}`);
23
+ lines.push(`- 备注:${env.notes.trim()}`);
24
24
  }
25
25
  if (lines.length === 0)
26
26
  return null;
27
27
  return [
28
- '## 评测环境前置(已就绪,无需探测)',
28
+ '## 题设环境声明(仅作上下文)',
29
29
  '',
30
30
  ...lines,
31
31
  '',
32
- '请直接进入 skill 描述的主流程,**不要做环境检查 / `Glob` / `find` / `which` / `test -f` 等探测**。',
32
+ '请按以上题设进入 skill 描述的主流程,**不要额外做 `find` / `which` / `test -f` 等可用性探测**。这些声明不会自动创建文件或修改 runtime 环境。',
33
33
  ].join('\n');
34
34
  }
35
35
  export function buildTasks(samples, variants, skills) {
@@ -28,6 +28,7 @@ import { finalizeSuccessfulRun, initializeEvaluationRunState, persistFailedJob,
28
28
  import { finalizeEvaluationReport } from './evaluation-pipeline/report-finalize.js';
29
29
  import { emitIsolationWarnings, emitPowerWarnings } from './evaluation-pipeline/preflight-warnings.js';
30
30
  import { ownRecordValue, setOwnRecordValue } from '../shared/record-count.js';
31
+ import { assertSamplesCompatibleWithExecutor } from '../executors/capabilities.js';
31
32
  // 兼容 re-export:测试与 run-evaluation.ts 动态 import 仍打 evaluation-pipeline.js
32
33
  export { buildPowerWarnings, buildIsolationWarnings } from './evaluation-pipeline/preflight-warnings.js';
33
34
  export { _computeTestSetHashForTest } from './evaluation-pipeline/test-set-hash.js';
@@ -35,6 +36,7 @@ export async function executeEvaluationPipeline({ samplesPath, samplesBaseDir, s
35
36
  // requires 现在由 runEvaluation 上游传给 doctor 处理; eval-pipeline 不再用
36
37
  // 但保留接口字段,避免破 programmatic API (类型层面接收, 内部忽略)
37
38
  requires: _requires, layeredStats = false, repeat, holdoutRatio, batch, judgeRepeat, judgeModels, bootstrap, bootstrapSamples, lengthDebias = true, budget, strictBaseline, runId, lang = 'zh', effort, noDiagnostic, }) {
39
+ assertSamplesCompatibleWithExecutor(samples, executorName, lang);
38
40
  const variantNames = artifacts.map((artifact) => artifact.name);
39
41
  const runState = await initializeEvaluationRunState({
40
42
  samplesPath,
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { DEFAULT_OUTPUT_DIR, persistReport } from '../eval-core/evaluation-reporting.js';
3
- import { createExecutor } from '../executors/index.js';
3
+ import { assertSamplesCompatibleWithExecutor, createExecutor, } from '../executors/index.js';
4
4
  import { discoverBatchSkills } from '../inputs/skill-loader.js';
5
5
  import { confidenceInterval, tTest, effectSize } from '../eval-core/statistics.js';
6
6
  import { executeBatchEvaluationRuns, buildBatchVariantSpecs } from './batch-evaluation-workflow.js';
@@ -26,6 +26,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
26
26
  mcpConfig,
27
27
  strictBaseline,
28
28
  });
29
+ assertSamplesCompatibleWithExecutor(samples, executorName, lang);
29
30
  // doctor 强制门禁: skill 静态结构 + 元数据 + 依赖 + 用例契约。
30
31
  // 在 dryRun 分支之前跑, 让 dry-run 也得到 doctor 覆盖(保护 garbage-in 的 verdict)。
31
32
  // 默认强制启用; --skip-doctor 提供 escape hatch,典型场景是评测环境用 mock/stub
@@ -0,0 +1,15 @@
1
+ import type { ExecutorFn, ExecutorInput, Sample } from '../types/index.js';
2
+ export type SampleMockSupport = 'native-hooks' | 'delegated-script' | 'unsupported';
3
+ export interface ExecutorCapabilities {
4
+ sampleMocks: SampleMockSupport;
5
+ }
6
+ /**
7
+ * Custom script executors receive the OMK_MOCK_* protocol environment and own
8
+ * the final adapter. Built-ins are explicit so unsupported runtimes can never
9
+ * silently turn mock assertions into model failures.
10
+ */
11
+ export declare function getExecutorCapabilities(executorName: string): ExecutorCapabilities;
12
+ export declare function executorSupportsSampleMocks(executorName: string): boolean;
13
+ export declare function assertSamplesCompatibleWithExecutor(samples: Sample[], executorName: string, lang?: 'zh' | 'en'): void;
14
+ export declare function assertExecutorInputCapabilities(executorName: string, input: ExecutorInput): void;
15
+ export declare function enforceExecutorCapabilities(executorName: string, executor: ExecutorFn): ExecutorFn;
@@ -0,0 +1,64 @@
1
+ const BUILTIN_CAPABILITIES = {
2
+ claude: { sampleMocks: 'native-hooks' },
3
+ 'claude-sdk': { sampleMocks: 'native-hooks' },
4
+ codex: { sampleMocks: 'unsupported' },
5
+ 'codex-sdk': { sampleMocks: 'unsupported' },
6
+ gemini: { sampleMocks: 'unsupported' },
7
+ 'anthropic-api': { sampleMocks: 'unsupported' },
8
+ 'openai-api': { sampleMocks: 'unsupported' },
9
+ };
10
+ /**
11
+ * Custom script executors receive the OMK_MOCK_* protocol environment and own
12
+ * the final adapter. Built-ins are explicit so unsupported runtimes can never
13
+ * silently turn mock assertions into model failures.
14
+ */
15
+ export function getExecutorCapabilities(executorName) {
16
+ return BUILTIN_CAPABILITIES[executorName]
17
+ ?? { sampleMocks: 'delegated-script' };
18
+ }
19
+ export function executorSupportsSampleMocks(executorName) {
20
+ return getExecutorCapabilities(executorName).sampleMocks !== 'unsupported';
21
+ }
22
+ function unsupportedMocksMessage(executorName, sampleIds, lang) {
23
+ const ids = sampleIds.slice(0, 8).join(', ');
24
+ const overflow = sampleIds.length > 8
25
+ ? lang === 'zh'
26
+ ? ` 等 ${sampleIds.length} 条`
27
+ : ` and ${sampleIds.length - 8} more`
28
+ : '';
29
+ if (lang === 'en') {
30
+ return `Executor "${executorName}" does not support Sample.mocks tool interception. `
31
+ + `Continuing would make mock_hit assertions structurally impossible and create false evidence. `
32
+ + `Affected samples: ${ids}${overflow}. `
33
+ + 'Regenerate them with "omk sample --no-mock", remove mocks/mock_hit, '
34
+ + 'or evaluate with claude/claude-sdk.';
35
+ }
36
+ return `执行器「${executorName}」不支持 Sample.mocks 工具拦截。`
37
+ + `继续运行会让 mock_hit 在结构上必然失败并产生伪证据。`
38
+ + `受影响用例:${ids}${overflow}。`
39
+ + '请用「omk sample --no-mock」重新生成、删除 mocks/mock_hit,'
40
+ + '或改用 claude/claude-sdk 评测。';
41
+ }
42
+ export function assertSamplesCompatibleWithExecutor(samples, executorName, lang = 'zh') {
43
+ if (executorSupportsSampleMocks(executorName))
44
+ return;
45
+ const affected = samples
46
+ .filter((sample) => Array.isArray(sample.mocks) && sample.mocks.length > 0)
47
+ .map((sample) => sample.sample_id);
48
+ if (affected.length === 0)
49
+ return;
50
+ throw new Error(unsupportedMocksMessage(executorName, affected, lang));
51
+ }
52
+ export function assertExecutorInputCapabilities(executorName, input) {
53
+ if (executorSupportsSampleMocks(executorName)
54
+ || !Array.isArray(input.mocks)
55
+ || input.mocks.length === 0)
56
+ return;
57
+ throw new Error(unsupportedMocksMessage(executorName, ['<programmatic-input>'], 'zh'));
58
+ }
59
+ export function enforceExecutorCapabilities(executorName, executor) {
60
+ return async (input) => {
61
+ assertExecutorInputCapabilities(executorName, input);
62
+ return executor(input);
63
+ };
64
+ }
@@ -2,4 +2,5 @@ import type { ExecutorFn } from '../types/index.js';
2
2
  import { extractAgentTrace } from './claude-sdk-trace.js';
3
3
  import { createScriptExecutor } from './script.js';
4
4
  export { extractAgentTrace, createScriptExecutor };
5
+ export { assertExecutorInputCapabilities, assertSamplesCompatibleWithExecutor, executorSupportsSampleMocks, getExecutorCapabilities, } from './capabilities.js';
5
6
  export declare function createExecutor(name: string): ExecutorFn;
@@ -7,6 +7,7 @@ import { codexSdkExecutor } from './codex-sdk.js';
7
7
  import { geminiExecutor } from './gemini.js';
8
8
  import { openAiApiExecutor } from './openai-api.js';
9
9
  import { createScriptExecutor } from './script.js';
10
+ import { enforceExecutorCapabilities } from './capabilities.js';
10
11
  // 命名一致性:provider HTTP 路径统一用 `<vendor>-api`(`anthropic-api` / `openai-api`),
11
12
  // vendor coding agent CLI 用 vendor 名(`claude` / `codex`)。`openai` 这个不带 -api 后缀的
12
13
  // 旧 alias 历史上指 openai-cli 子进程实现,删除后不再设别名 — 用 `--executor openai-api`。
@@ -20,9 +21,11 @@ const EXECUTOR_REGISTRY = {
20
21
  'openai-api': openAiApiExecutor,
21
22
  };
22
23
  export { extractAgentTrace, createScriptExecutor };
24
+ export { assertExecutorInputCapabilities, assertSamplesCompatibleWithExecutor, executorSupportsSampleMocks, getExecutorCapabilities, } from './capabilities.js';
23
25
  export function createExecutor(name) {
24
26
  if (name.trim().length === 0) {
25
27
  throw new Error('executor name or script command is required');
26
28
  }
27
- return EXECUTOR_REGISTRY[name] || createScriptExecutor(name);
29
+ const executor = EXECUTOR_REGISTRY[name] || createScriptExecutor(name);
30
+ return enforceExecutorCapabilities(name, executor);
28
31
  }
@@ -250,7 +250,7 @@ function detectSkillDocGap(doctor, evalReport, observe, variantName) {
250
250
  const recs = [];
251
251
  if (depRule) {
252
252
  recs.push({
253
- action: `在 sample.environment.files_available 加上文件路径,告诉 LLM "这些文件已就绪,无需探测"`,
253
+ action: '把已知路径写进 sample.context;只有纯题设前提才放 environment.files_available,且不要把它当作已物化 fixture',
254
254
  priority: severity,
255
255
  patch: {
256
256
  target: 'sample-environment',
@@ -1,3 +1,4 @@
1
1
  export declare function assertionContractValidationError(value: unknown, depth?: number, insideAssertSet?: boolean): string | undefined;
2
+ export declare function sampleMockReferenceKeys(value: unknown): ReadonlySet<string>;
2
3
  export declare function dependencyRequirementsValidationError(value: unknown): string | undefined;
3
4
  export declare function sampleContractValidationError(value: unknown, expectedId?: string): string | undefined;
@@ -219,6 +219,38 @@ function mockValidationError(value) {
219
219
  return '"match.input" must be a JSON object when present';
220
220
  return undefined;
221
221
  }
222
+ export function sampleMockReferenceKeys(value) {
223
+ const keys = new Set();
224
+ if (!Array.isArray(value))
225
+ return keys;
226
+ const countByTool = new Map();
227
+ for (const mock of value) {
228
+ if (!isRecord(mock) || !isNonEmptyString(mock.tool))
229
+ continue;
230
+ const ordinal = (countByTool.get(mock.tool) ?? 0) + 1;
231
+ countByTool.set(mock.tool, ordinal);
232
+ keys.add(`${mock.tool}:${ordinal}`);
233
+ }
234
+ return keys;
235
+ }
236
+ function mockHitReferenceValidationError(assertions, mockKeys) {
237
+ if (!Array.isArray(assertions))
238
+ return undefined;
239
+ for (const assertion of assertions) {
240
+ if (!isRecord(assertion))
241
+ continue;
242
+ if (assertion.type === 'mock_hit'
243
+ && typeof assertion.value === 'string'
244
+ && !mockKeys.has(assertion.value)) {
245
+ const available = mockKeys.size > 0 ? [...mockKeys].join(', ') : '(none)';
246
+ return `"mock_hit" references missing mock ${JSON.stringify(assertion.value)}; available mock keys: ${available}`;
247
+ }
248
+ const childError = mockHitReferenceValidationError(assertion.children, mockKeys);
249
+ if (childError)
250
+ return childError;
251
+ }
252
+ return undefined;
253
+ }
222
254
  export function dependencyRequirementsValidationError(value) {
223
255
  if (!isRecord(value))
224
256
  return '"requires" must be an object';
@@ -276,6 +308,9 @@ export function sampleContractValidationError(value, expectedId) {
276
308
  return `"mocks[${index}]": ${error}`;
277
309
  }
278
310
  }
311
+ const mockHitError = mockHitReferenceValidationError(value.assertions, sampleMockReferenceKeys(value.mocks));
312
+ if (mockHitError)
313
+ return mockHitError;
279
314
  if (value.mocksStrict !== undefined && typeof value.mocksStrict !== 'boolean') {
280
315
  return '"mocksStrict" must be boolean when present';
281
316
  }
@@ -19,3 +19,11 @@ export interface ToolIdentityInput {
19
19
  * retaining source identity for audit and future protocol migrations.
20
20
  */
21
21
  export declare function normalizeToolIdentity(input: ToolIdentityInput): NormalizedToolIdentity;
22
+ /**
23
+ * Match a source-neutral tool identity against a runtime-native tool name.
24
+ *
25
+ * Exact matching remains first for legacy/custom tools. Normalization then lets
26
+ * one mock identity work across adapters such as `Bash` ↔ `exec_command` and
27
+ * `Edit` ↔ `apply_patch`.
28
+ */
29
+ export declare function toolIdentityMatches(expectedName: string, runtimeName: string): boolean;
@@ -62,6 +62,19 @@ export function normalizeToolIdentity(input) {
62
62
  ...(sourceName !== name ? { sourceName } : {}),
63
63
  };
64
64
  }
65
+ /**
66
+ * Match a source-neutral tool identity against a runtime-native tool name.
67
+ *
68
+ * Exact matching remains first for legacy/custom tools. Normalization then lets
69
+ * one mock identity work across adapters such as `Bash` ↔ `exec_command` and
70
+ * `Edit` ↔ `apply_patch`.
71
+ */
72
+ export function toolIdentityMatches(expectedName, runtimeName) {
73
+ if (expectedName === runtimeName)
74
+ return true;
75
+ return normalizeToolIdentity({ sourceName: expectedName }).name
76
+ === normalizeToolIdentity({ sourceName: runtimeName }).name;
77
+ }
65
78
  function inferredMcpNamespace(sourceName) {
66
79
  const parts = sourceName.split('__').filter(Boolean);
67
80
  return parts[0] === 'mcp' && parts.length > 2
@@ -35,12 +35,12 @@ export interface SampleCoverageTarget {
35
35
  /** 稳定引用:路径或 ID。reference/script 用 skill 根相对路径,workflow_node 用 workflowId.nodeId。 */
36
36
  ref: string;
37
37
  }
38
- /** Sample 评测环境前置:声明性"已就绪"清单,LLM 看到后跳过环境探测,直接进入工作流。
39
- * 类比 unit test 的 fixture / setup —— 评测是测 skill 工作流,不是测环境探测能力。 */
38
+ /** Sample 题设环境声明。仅注入 prompt,不会修改 PATH、物化文件或改变 runtime。 */
40
39
  export interface SampleEnvironment {
41
- /** 假定已在 PATH 上的 CLI,LLM 不再 which / find / type / command -v 探测。 */
40
+ /** 题设声明可用的 CLI,LLM 不再 which / find / type / command -v 探测。 */
42
41
  cli_available?: string[];
43
- /** 假定存在的文件/脚本(支持 ~ / $SKILL_DIR / 绝对路径),LLM 不再 Glob / Read / test -f 探测。 */
42
+ /** 题设声明存在的文件/脚本(支持 ~ / $SKILL_DIR / 绝对路径),LLM 不再 Glob / Read / test -f 探测。
43
+ * 这是 prompt context,不会在 cwd 物化文件;需要真实读取时必须提供 sample.cwd 中的 fixture。 */
44
44
  files_available?: string[];
45
45
  /** 自由文本兜底,场景特殊说明(如"凭证已配""设备 SN xxx 已租"等)。 */
46
46
  notes?: string;
@@ -80,7 +80,8 @@ export type MockReturn = {
80
80
  } | string;
81
81
  /** 单条 Mock 规则。runtime 拦到匹配的 tool 调用即返回 mocked 结果,不放出去。 */
82
82
  export interface Mock {
83
- /** 拦截的工具名,如 "Read" / "Bash" / "WebFetch" / "Edit" / "Write" / "Grep" / "Glob"。
83
+ /** source-neutral 工具身份,如 "Read" / "Bash" / "WebFetch" / "Edit" / "Write" / "Grep" / "Glob"。
84
+ * executor adapter 会把 runtime-native 名称(如 exec_command / apply_patch)映射后匹配。
84
85
  * 特殊值 `"*"`:通配,匹配任何工具名(配合 match.input_contains 做 intent-level mock)。 */
85
86
  tool: string;
86
87
  /** 命中规则。所有字段 AND,字段未填即不限制。 */
@@ -136,7 +137,7 @@ export interface Sample {
136
137
  * - true:未命中即 deny(防意外真调外部接口/CLI/MCP/写状态)。
137
138
  * 全 mock 评测场景建议 true,部分 mock 探索场景留 false。 */
138
139
  mocksStrict?: boolean;
139
- /** 环境前置:声明性"已就绪"清单,LLM 跳过探测直接干活。详见 SampleEnvironment。 */
140
+ /** 题设环境声明,仅作 prompt 上下文。详见 SampleEnvironment。 */
140
141
  environment?: SampleEnvironment;
141
142
  [key: string]: unknown;
142
143
  }
@@ -87,8 +87,9 @@ export interface ExecutorInput {
87
87
  * - claude-sdk:转 in-process HookCallback 装到 SDK options.hooks.PreToolUse
88
88
  * - claude-cli:物化为临时 settings.json + on-disk hook 脚本,跑完清理
89
89
  * - script(自定义脚本):同样物化临时 settings,通过 env(OMK_MOCK_SETTINGS_FILE /
90
- * OMK_MOCK_MCP_CONFIG_FILE / OMK_MOCKS_FILE)暴露给脚本;脚本若包 Claude Code 兼容
91
- * CLI 可透传 --settings 复用同一 mock hook,否则忽略(不支持的 CLI 静默无 mock) */
90
+ * OMK_MOCK_MCP_CONFIG_FILE / OMK_MOCKS_FILE)暴露给脚本;脚本负责消费该协议
91
+ * - codex / codex-sdk / gemini / *-api:不支持,executor capability gate 会拒绝,
92
+ * 绝不静默忽略后把 mock_hit 记成模型失败 */
92
93
  mocks?: import('./eval.js').Mock[];
93
94
  /** 解析 mock.return_file 的相对路径锚点(默认 sample 文件所在目录)。 */
94
95
  mocksBaseDir?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-knowledge",
3
- "version": "0.51.1",
3
+ "version": "0.51.2",
4
4
  "packageManager": "yarn@4.16.0",
5
5
  "description": "Evaluation framework for LLM knowledge inputs — prompts, RAG corpora, skills, agent workflows. Fix the model, vary the artifact. Built-in statistical rigor: bootstrap CI, Krippendorff α, length-debias, saturation curves.",
6
6
  "type": "module",
@@ -96,11 +96,11 @@
96
96
  "license": "MIT",
97
97
  "dependencies": {
98
98
  "@anthropic-ai/claude-agent-sdk": "^0.3.143",
99
- "@anthropic-ai/sdk": "^0.112.3",
99
+ "@anthropic-ai/sdk": "^0.115.0",
100
100
  "@inquirer/prompts": "^8.4.3",
101
101
  "@modelcontextprotocol/sdk": "^1.29.0",
102
102
  "@oclif/core": "^4",
103
- "@openai/codex-sdk": "0.144.6",
103
+ "@openai/codex-sdk": "0.145.0",
104
104
  "ajv": "^8.18.0",
105
105
  "chart.js": "^4.5.1",
106
106
  "es-module-lexer": "^2.0.0",
@@ -116,7 +116,7 @@
116
116
  "@types/node": "^25.5.0",
117
117
  "eslint": "^10.1.0",
118
118
  "husky": "^9.1.7",
119
- "lint-staged": "17.1.0",
119
+ "lint-staged": "17.2.0",
120
120
  "npm-run-all2": "^9.0.1",
121
121
  "typescript": "^6.0.2",
122
122
  "typescript-eslint": "^8.58.0",