oh-my-knowledge 0.38.0 → 0.40.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 (55) hide show
  1. package/dist/assets/agent-skills/omk/references/commands.md +2 -0
  2. package/dist/authoring/evolver.js +9 -5
  3. package/dist/cli/commands/list.d.ts +4 -1
  4. package/dist/cli/commands/list.js +12 -4
  5. package/dist/cli/commands/observe/index.d.ts +9 -0
  6. package/dist/cli/commands/observe/index.js +74 -1
  7. package/dist/cli/commands/sample.d.ts +13 -0
  8. package/dist/cli/commands/sample.js +113 -16
  9. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  10. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  11. package/dist/cli/lib/i18n-dict/common.js +8 -0
  12. package/dist/cli/lib/i18n-dict/gen.d.ts +1 -1
  13. package/dist/cli/lib/i18n-dict/gen.js +8 -0
  14. package/dist/cli/lib/i18n-dict/list.d.ts +1 -1
  15. package/dist/cli/lib/i18n-dict/list.js +4 -0
  16. package/dist/cli/lib/record-evolve-outcome.js +19 -10
  17. package/dist/eval-core/bootstrap.d.ts +53 -2
  18. package/dist/eval-core/bootstrap.js +82 -5
  19. package/dist/eval-core/evaluation-reporting.js +37 -15
  20. package/dist/eval-core/execution-strategy.js +1 -0
  21. package/dist/eval-core/schema.js +5 -3
  22. package/dist/eval-core/verdict.d.ts +30 -0
  23. package/dist/eval-core/verdict.js +71 -21
  24. package/dist/grading/assertions.js +28 -0
  25. package/dist/grading/debias-validate.js +6 -2
  26. package/dist/grading/index.js +6 -1
  27. package/dist/grading/layered-scores.d.ts +15 -0
  28. package/dist/grading/layered-scores.js +64 -38
  29. package/dist/inputs/load-samples.d.ts +5 -0
  30. package/dist/inputs/load-samples.js +4 -2
  31. package/dist/inputs/skill-loader.d.ts +8 -0
  32. package/dist/inputs/skill-loader.js +23 -0
  33. package/dist/managed/evidence.d.ts +2 -1
  34. package/dist/managed/evidence.js +15 -2
  35. package/dist/managed/index.d.ts +2 -0
  36. package/dist/managed/index.js +2 -0
  37. package/dist/managed/list-view.d.ts +15 -1
  38. package/dist/managed/list-view.js +9 -1
  39. package/dist/managed/observe-feedback.d.ts +60 -0
  40. package/dist/managed/observe-feedback.js +49 -0
  41. package/dist/managed/store.d.ts +38 -1
  42. package/dist/managed/store.js +116 -3
  43. package/dist/managed/version-scores.d.ts +33 -0
  44. package/dist/managed/version-scores.js +70 -0
  45. package/dist/observability/skill-health-analyzer.d.ts +5 -0
  46. package/dist/observability/skill-health-analyzer.js +3 -2
  47. package/dist/renderer/managed-history-renderer.d.ts +2 -2
  48. package/dist/renderer/managed-history-renderer.js +189 -7
  49. package/dist/renderer/summary.js +23 -22
  50. package/dist/server/report-server.js +11 -2
  51. package/dist/types/eval.d.ts +2 -0
  52. package/dist/types/judge.d.ts +8 -0
  53. package/dist/types/managed.d.ts +40 -0
  54. package/dist/types/report.d.ts +10 -0
  55. package/package.json +3 -3
@@ -403,6 +403,7 @@ omk observe [sessionsDir] [flags]
403
403
 
404
404
  **Flags:**
405
405
 
406
+ - `--feedback` `boolean`:把生产健康观测反哺已纳管的同名 skill(--no-feedback 关闭)
406
407
  - `--from` `option`:起始时间 ISO,优先级高于 --last
407
408
  - `--global` `boolean`:写全局 ~/.oh-my-knowledge/observe-health,而非项目 .omk/observe-health
408
409
  - `--kb` `option`:知识库 root,启用 KB-aware 分析
@@ -584,6 +585,7 @@ omk sample [skillPath] [flags]
584
585
 
585
586
  **Flags:**
586
587
 
588
+ - `--append` `boolean`:在已有用例文件上追加新生成的用例(撞 sample_id 自动加后缀去重,保留原 json/yaml 格式)。仅单 skill 模式,不支持 --batch / --from-traces / --fix。不传则已有文件时报错保护。常配 --focus 补特定场景。
587
589
  - `--batch` `boolean`:批量模式:扫 --skill-dir 下所有缺 samples 的 skill,逐个生成。
588
590
  - `--count` `option`:生成用例条数。不传由 LLM 按 skill 类型自动决定。
589
591
  - `--executor` `option`:执行器名,默认 claude(同 omk eval / doctor / evolve)。指定 codex 等其它执行器时,记得连带传一个该执行器能识别的 --model。
@@ -868,11 +868,15 @@ export async function evolveSkill({ skillPath, samplesPath, rounds = 5, target =
868
868
  // Significance accept gate: accept only when the candidate is *significantly*
869
869
  // above the current best on the decision (val) set, not merely numerically higher
870
870
  // — rejecting gains indistinguishable from judge noise. `lastReport` is the current
871
- // best's fresh eval and `candidateReport` the candidate's, over the same samples;
872
- // bootstrapDiffCI resamples the two arrays independently (conservative not a paired
873
- // bootstrap). Under-powered decision sets degrade to the legacy point-estimate accept
874
- // (note: that path compares the prior-round best scalar, not this fresh re-eval) and
875
- // flag `gate.underpowered`.
871
+ // best's fresh eval and `candidateReport` the candidate's, over the same samples.
872
+ // **Deliberately UNPAIRED here** (independent resampling, `decideAccept` `bootstrapDiffCI`),
873
+ // unlike the report verdict / debias which switched to `bootstrapPairedDiffCI`: this is an
874
+ // *optimization accept-gate*, and the unpaired bootstrap's wider, conservative CI is the
875
+ // wanted bias — it raises the bar to accept, so evolve does not chase paired-tightened,
876
+ // marginally-significant gains that risk overfitting the decision set. Power is not the goal
877
+ // for de-/escalation gates; not accepting noise is. Under-powered decision sets degrade to the
878
+ // legacy point-estimate accept (note: that path compares the prior-round best scalar, not this
879
+ // fresh re-eval) and flag `gate.underpowered`.
876
880
  const valIds = split ? split.valIds : new Set(allSampleIds);
877
881
  const bestScores = perSampleComposite(lastReport, lastVariantKey, valIds);
878
882
  const candScores = perSampleComposite(candidateReport, candidateVariantKey, valIds);
@@ -2,7 +2,10 @@ import { BaseCommand } from '../oclif/base-command.js';
2
2
  import { sanitizeCell } from '../lib/cell-format.js';
3
3
  import { type ManagedListRow } from '../../managed/index.js';
4
4
  import type { CliLang } from '../lib/i18n.js';
5
- /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
5
+ /** CJK 全角字符 + 星平面 emoji 按 2 列计宽,使含中文表头 / emoji 标记(如 🔬)的列也能对齐。
6
+ * 逐**码点**迭代(for...of),星平面 emoji 是单码点,加 `\u{1F300}-\u{1FAFF}`(含 🔬 U+1F52C)判 2 列。
7
+ * 注:`⚠️` 是「U+26A0 + U+FE0F」两码点、各 1 列合计 2,本就与 2 列渲染对齐,不另判(避免重复计数);
8
+ * `✓`(U+2713)终端按 1 列渲染、保持 1。 */
6
9
  export declare function dispWidth(s: string): number;
7
10
  /** 按**显示宽度**截断(不是 code unit):逐码点累加 dispWidth,绝不切断 surrogate 对、CJK 也不溢出列。 */
8
11
  export declare function truncate(s: string, max: number): string;
@@ -4,11 +4,14 @@ import { BaseCommand } from '../oclif/base-command.js';
4
4
  import { tCli } from '../lib/i18n.js';
5
5
  import { sanitizeCell } from '../lib/cell-format.js';
6
6
  import { globalManagedDir, listManagedRows, managedDir, resolveManagedDir, } from '../../managed/index.js';
7
- /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
7
+ /** CJK 全角字符 + 星平面 emoji 按 2 列计宽,使含中文表头 / emoji 标记(如 🔬)的列也能对齐。
8
+ * 逐**码点**迭代(for...of),星平面 emoji 是单码点,加 `\u{1F300}-\u{1FAFF}`(含 🔬 U+1F52C)判 2 列。
9
+ * 注:`⚠️` 是「U+26A0 + U+FE0F」两码点、各 1 列合计 2,本就与 2 列渲染对齐,不另判(避免重复计数);
10
+ * `✓`(U+2713)终端按 1 列渲染、保持 1。 */
8
11
  export function dispWidth(s) {
9
12
  let w = 0;
10
13
  for (const ch of s)
11
- w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1;
14
+ w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1FAFF}]/u.test(ch) ? 2 : 1;
12
15
  return w;
13
16
  }
14
17
  function pad(s, width) {
@@ -45,7 +48,9 @@ export function renderTable(rows, lang) {
45
48
  truncate(sanitizeCell(r.name), 40), // name 与 source 同为用户可控、可超长 → 同样按显示宽度截断,防撑爆表宽
46
49
  r.kind, // ArtifactKind 枚举(validator 已收窄),无需洗
47
50
  // 不可达 → 标「?」(drift 未核),绝不冒充 stale;reachable 且漂移才 stale ⚠️;已人工接受标 promoted ✓。
48
- !r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state === 'promoted' ? 'promoted ✓' : r.state,
51
+ // 生产盲区 🔬 与生命周期**正交**(observe 量线上部署版),叠加在 state token 之后,不替换 state
52
+ (!r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state === 'promoted' ? 'promoted ✓' : r.state)
53
+ + (r.productionGap ? ' 🔬' : ''),
49
54
  r.latestVerdict ? sanitizeCell(r.latestVerdict) : '—',
50
55
  `${r.currentEvidenceCount}/${r.totalEvidenceCount}`,
51
56
  truncate(sanitizeCell(r.sourceLabel), 48),
@@ -99,7 +104,8 @@ export default class List extends BaseCommand {
99
104
  const hasDrift = rows.some((r) => r.drifted);
100
105
  const hasUnreachable = rows.some((r) => !r.reachable);
101
106
  const hasPromoted = rows.some((r) => r.state === 'promoted');
102
- if (hasDrift || hasUnreachable || hasPromoted)
107
+ const hasProductionGap = rows.some((r) => r.productionGap);
108
+ if (hasDrift || hasUnreachable || hasPromoted || hasProductionGap)
103
109
  process.stderr.write('\n');
104
110
  if (hasPromoted)
105
111
  process.stderr.write(tCli('cli.list.promoted_note', lang));
@@ -107,6 +113,8 @@ export default class List extends BaseCommand {
107
113
  process.stderr.write(tCli('cli.list.drift_note', lang));
108
114
  if (hasUnreachable)
109
115
  process.stderr.write(tCli('cli.list.unreachable_note', lang));
116
+ if (hasProductionGap)
117
+ process.stderr.write(tCli('cli.list.production_gap_note', lang));
110
118
  process.stderr.write(tCli('cli.list.legend', lang));
111
119
  });
112
120
  }
@@ -9,6 +9,14 @@ export declare function persistObserveHealthReport(report: SkillHealthReport, ou
9
9
  id: string;
10
10
  jsonPath: string;
11
11
  };
12
+ /**
13
+ * SkillHealthReport → managed 反哺的结构化最小入参(#235)。纯映射、可单测 —— 把「observe 报告 →
14
+ * ObserveReportView」这段层间胶水从 CLI 副作用里拆出来,免得 healthBand 取错字段 / observedAt 取错时刻
15
+ * 这类映射 bug 无人验。`observedAt` 取**流量窗口结束时刻**(timeRange.to,空则退 generatedAt),不是「此刻」
16
+ * 的 generatedAt —— 否则 latest-wins 会把所有观测当成一样新(见 ManagedObservation.observedAt)。
17
+ * `healthBand` 由 observability 的 `healthBandOf` 逐 skill 算(阈值单一来源,注入以保可测)。
18
+ */
19
+ export declare function buildObserveReportView(report: SkillHealthReport, reportId: string, healthBandOf: (weightedGapRate: number) => 'green' | 'yellow' | 'red'): import('../../../managed/index.js').ObserveReportView;
12
20
  export default class Observe extends BaseCommand {
13
21
  static description: string;
14
22
  static examples: {
@@ -27,6 +35,7 @@ export default class Observe extends BaseCommand {
27
35
  skills: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
28
36
  'output-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
29
37
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
38
+ feedback: import("@oclif/core/interfaces").BooleanFlag<boolean>;
30
39
  };
31
40
  run(): Promise<void>;
32
41
  }
@@ -23,6 +23,68 @@ export function persistObserveHealthReport(report, outDir) {
23
23
  indexObserveWrite(report, jsonPath, outDir, id);
24
24
  return { id, jsonPath };
25
25
  }
26
+ // 盲区信号类型 → 人话标签(建议补样本提示用)。技术枚举键的展示名,zh/en 分列。
27
+ const GAP_AREA_LABELS = {
28
+ failed_search: { zh: '检索失败', en: 'failed search' },
29
+ explicit_marker: { zh: '显式缺口', en: 'explicit gap' },
30
+ hedging: { zh: '含糊回避', en: 'hedging' },
31
+ repeated_failure: { zh: '反复失败', en: 'repeated failure' },
32
+ };
33
+ /** 取盲区计数最高的前几类,组成「建议补哪类用例」的人话区域串(只展示信号所在,不生成具体用例)。
34
+ * 只迭代**四个已知盲区类型**(GAP_AREA_LABELS 的键),记录 / 报告里若混入额外键一律忽略,不进展示。 */
35
+ function topGapAreas(gapByType, lang) {
36
+ const sep = lang === 'zh' ? '、' : ', ';
37
+ const areas = Object.keys(GAP_AREA_LABELS)
38
+ .map((k) => [k, gapByType[k] ?? 0])
39
+ .filter(([, n]) => n > 0)
40
+ .sort((a, b) => b[1] - a[1])
41
+ .slice(0, 3)
42
+ .map(([k]) => GAP_AREA_LABELS[k][lang])
43
+ .join(sep);
44
+ return areas || (lang === 'zh' ? '未归类盲区' : 'uncategorized gaps');
45
+ }
46
+ /**
47
+ * SkillHealthReport → managed 反哺的结构化最小入参(#235)。纯映射、可单测 —— 把「observe 报告 →
48
+ * ObserveReportView」这段层间胶水从 CLI 副作用里拆出来,免得 healthBand 取错字段 / observedAt 取错时刻
49
+ * 这类映射 bug 无人验。`observedAt` 取**流量窗口结束时刻**(timeRange.to,空则退 generatedAt),不是「此刻」
50
+ * 的 generatedAt —— 否则 latest-wins 会把所有观测当成一样新(见 ManagedObservation.observedAt)。
51
+ * `healthBand` 由 observability 的 `healthBandOf` 逐 skill 算(阈值单一来源,注入以保可测)。
52
+ */
53
+ export function buildObserveReportView(report, reportId, healthBandOf) {
54
+ return {
55
+ reportId,
56
+ observedAt: report.meta.timeRange?.to || report.meta.generatedAt,
57
+ skills: Object.values(report.bySkill).map((s) => ({
58
+ skillName: s.skillName,
59
+ segmentCount: s.segmentCount,
60
+ gapRate: s.gap.gapRate,
61
+ weightedGapRate: s.gap.weightedGapRate,
62
+ confidence: s.confidence,
63
+ healthBand: healthBandOf(s.gap.weightedGapRate),
64
+ gapByType: s.gap.byType,
65
+ })),
66
+ };
67
+ }
68
+ /**
69
+ * observe → 管理支柱反哺(#235):把每个 skill 的生产健康落成观测追加进同名受管记录,并打印「已记录 / 生产
70
+ * 盲区警示」。**非致命**:管理是 observe 旁路,任何异常都不该让 observe 失败(try/catch 吞掉)。observability /
71
+ * managed 运行时函数动态 import,与 observe 主体一致、不拖累 CLI 启动。
72
+ */
73
+ async function recordObserveFeedback(report, reportId, lang) {
74
+ try {
75
+ const { healthBandOf } = await import('../../../observability/skill-health-analyzer.js');
76
+ const { recordObserveHealth } = await import('../../../managed/index.js');
77
+ const written = recordObserveHealth(buildObserveReportView(report, reportId, healthBandOf));
78
+ for (const w of written) {
79
+ process.stdout.write(w.isProductionGap
80
+ ? tCli('cli.observe.production_gap', lang, { name: w.name, areas: topGapAreas(w.gapByType, lang) })
81
+ : tCli('cli.observe.observation_recorded', lang, { name: w.name }));
82
+ }
83
+ }
84
+ catch {
85
+ // 反哺是 observe 旁路,任何异常都不该让 observe 失败。
86
+ }
87
+ }
26
88
  // `omk observe <sessions-dir>` 是默认命令 —— 分析 sessions 目录的 skill 调用健康度,产出 observe-health 报告(JSON),
27
89
  // 由 Studio 健康报告页按需渲染。observe 这条线的另一条产物是观测收件箱(observe-inbox),走子命令 ingest / inbox / show。
28
90
  export default class Observe extends BaseCommand {
@@ -80,6 +142,14 @@ export default class Observe extends BaseCommand {
80
142
  en: 'Write to global ~/.oh-my-knowledge/observe-health instead of project .omk/observe-health',
81
143
  }),
82
144
  }),
145
+ feedback: Flags.boolean({
146
+ default: true,
147
+ allowNo: true,
148
+ description: bilingual({
149
+ zh: '把生产健康观测反哺已纳管的同名 skill(--no-feedback 关闭)',
150
+ en: 'Feed production-health observations back to managed skills of the same name (--no-feedback to disable)',
151
+ }),
152
+ }),
83
153
  };
84
154
  async run() {
85
155
  const { args, flags } = await this.parse(Observe);
@@ -120,7 +190,7 @@ export default class Observe extends BaseCommand {
120
190
  const outDir = flags['output-dir']
121
191
  ? resolve(flags['output-dir'])
122
192
  : (flags.global ? globalObserveHealthDir() : projectObserveHealthDir());
123
- const { jsonPath } = persistObserveHealthReport(report, outDir);
193
+ const { id, jsonPath } = persistObserveHealthReport(report, outDir);
124
194
  const { sessionCount, segmentCount, toolCallCount, toolFailureRate } = report.meta;
125
195
  console.log('');
126
196
  console.log(`sessions: ${sessionCount} · segments: ${segmentCount} · tool calls: ${toolCallCount} · fail rate: ${(toolFailureRate * 100).toFixed(1)}%`);
@@ -139,6 +209,9 @@ export default class Observe extends BaseCommand {
139
209
  console.log('');
140
210
  console.log(`report written to: ${jsonPath}`);
141
211
  console.log(tCli('cli.observe.view_hint', lang));
212
+ // #235 受管反哺:把生产健康观测落进同名受管 skill(--no-feedback 关)。非致命旁路。
213
+ if (flags.feedback)
214
+ await recordObserveFeedback(report, id, lang);
142
215
  });
143
216
  }
144
217
  }
@@ -2,6 +2,18 @@ import { BaseCommand } from '../oclif/base-command.js';
2
2
  import { type CliLang } from '../lib/i18n.js';
3
3
  import { type LoadSamplesResult } from '../../inputs/load-samples.js';
4
4
  import type { Report, Sample as SampleType } from '../../types/index.js';
5
+ /** --append 合并:已有用例原样保留,新用例逐条接在后面;sample_id 撞已有(或本批已用)时
6
+ * 自动加 `-2`/`-3` 后缀去重。模型每次从 s001 重编号,撞 id 不代表内容重复,所以是改名保留
7
+ * 而非丢弃(不做内容级去重)。`reserved` 为额外要避开的 id 集(目录模式跨同目录其它 sample
8
+ * 文件去重用,见 collectDirSampleIds)。 */
9
+ export declare function mergeAppendSamples(existing: SampleType[], fresh: SampleType[], reserved?: ReadonlySet<string>): SampleType[];
10
+ /** 目录模式 append 选写回目标:复用 listSampleFilesInDir 的排序/过滤(与 eval 目录合并同口径),
11
+ * 优先 canonical `samples.json`,否则排序后第一个 —— 确定性、不依赖文件系统枚举顺序,
12
+ * 用户可预测改哪个文件。无候选返回 null。 */
13
+ export declare function pickAppendTargetFile(dir: string): string | null;
14
+ /** 把新用例追加进已有 sample 文件:读 → 合并(撞 id 去重)→ 保留原 json/yaml 格式与
15
+ * `{samples:[...]}` wrapper 写回。返回合并后总条数。 */
16
+ export declare function appendSamplesToFile(existingFile: string, fresh: SampleType[], reserved?: ReadonlySet<string>): number;
5
17
  export declare function collectSampleDesignFailureIds(report: Pick<Report, 'results'>, treatmentName: string): Set<string>;
6
18
  export declare function assertFixReportMatchesCurrentInputs(params: {
7
19
  report: Pick<Report, 'meta'>;
@@ -29,6 +41,7 @@ export default class Sample extends BaseCommand {
29
41
  executor: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
30
42
  'skill-dir': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
31
43
  focus: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
44
+ append: import("@oclif/core/interfaces").BooleanFlag<boolean>;
32
45
  'no-mock': import("@oclif/core/interfaces").BooleanFlag<boolean>;
33
46
  fix: import("@oclif/core/interfaces").BooleanFlag<boolean>;
34
47
  'reports-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -8,7 +8,7 @@ import { integerStringParser } from '../oclif/parsers.js';
8
8
  import { CliExit } from '../lib/cli-exit.js';
9
9
  import { tCli } from '../lib/i18n.js';
10
10
  import { projectReportsDir, globalReportsDir } from '../../eval-core/measurement-dirs.js';
11
- import { loadSamples, parseYaml } from '../../inputs/load-samples.js';
11
+ import { loadSamples, parseYaml, listSampleFilesInDir } from '../../inputs/load-samples.js';
12
12
  import { hashSample } from '../../eval-core/evaluation-reporting.js';
13
13
  import { hashArtifactSource } from '../../inputs/content-hash.js';
14
14
  function isRecord(value) {
@@ -33,6 +33,78 @@ function stringifySampleDocument(filePath, document) {
33
33
  return yaml.dump(document, { lineWidth: -1, noRefs: true });
34
34
  return JSON.stringify(document, null, 2);
35
35
  }
36
+ /** --append 合并:已有用例原样保留,新用例逐条接在后面;sample_id 撞已有(或本批已用)时
37
+ * 自动加 `-2`/`-3` 后缀去重。模型每次从 s001 重编号,撞 id 不代表内容重复,所以是改名保留
38
+ * 而非丢弃(不做内容级去重)。`reserved` 为额外要避开的 id 集(目录模式跨同目录其它 sample
39
+ * 文件去重用,见 collectDirSampleIds)。 */
40
+ export function mergeAppendSamples(existing, fresh, reserved) {
41
+ const used = new Set(existing.map((s) => s.sample_id));
42
+ if (reserved)
43
+ for (const id of reserved)
44
+ used.add(id);
45
+ const merged = [...existing];
46
+ for (const sample of fresh) {
47
+ let id = sample.sample_id;
48
+ if (used.has(id)) {
49
+ let n = 2;
50
+ while (used.has(`${id}-${n}`))
51
+ n += 1;
52
+ id = `${id}-${n}`;
53
+ }
54
+ used.add(id);
55
+ merged.push(id === sample.sample_id ? sample : { ...sample, sample_id: id });
56
+ }
57
+ return merged;
58
+ }
59
+ /** 目录模式 append:收集目录内所有 sample 文件的 sample_id,跨文件去重用 —— eval 走目录模式
60
+ * 会把目录下所有文件合并加载,跨文件撞 id 直接报错(load-samples 的 duplicate sample_id)。
61
+ * 复用 listSampleFilesInDir 的排序/过滤口径;best-effort:解析失败的文件跳过。 */
62
+ function collectDirSampleIds(dir) {
63
+ const ids = new Set();
64
+ let files;
65
+ try {
66
+ files = listSampleFilesInDir(dir);
67
+ }
68
+ catch {
69
+ return ids;
70
+ }
71
+ for (const f of files) {
72
+ const full = join(dir, f);
73
+ try {
74
+ for (const s of getSamplesArray(parseSampleDocument(full), full)) {
75
+ if (typeof s.sample_id === 'string')
76
+ ids.add(s.sample_id);
77
+ }
78
+ }
79
+ catch { /* skip unparseable / 非 sample 文件 */ }
80
+ }
81
+ return ids;
82
+ }
83
+ /** 目录模式 append 选写回目标:复用 listSampleFilesInDir 的排序/过滤(与 eval 目录合并同口径),
84
+ * 优先 canonical `samples.json`,否则排序后第一个 —— 确定性、不依赖文件系统枚举顺序,
85
+ * 用户可预测改哪个文件。无候选返回 null。 */
86
+ export function pickAppendTargetFile(dir) {
87
+ let files;
88
+ try {
89
+ files = listSampleFilesInDir(dir);
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ if (files.length === 0)
95
+ return null;
96
+ const chosen = files.includes('samples.json') ? 'samples.json' : files[0];
97
+ return join(dir, chosen);
98
+ }
99
+ /** 把新用例追加进已有 sample 文件:读 → 合并(撞 id 去重)→ 保留原 json/yaml 格式与
100
+ * `{samples:[...]}` wrapper 写回。返回合并后总条数。 */
101
+ export function appendSamplesToFile(existingFile, fresh, reserved) {
102
+ const doc = parseSampleDocument(existingFile);
103
+ const merged = mergeAppendSamples(getSamplesArray(doc, existingFile), fresh, reserved);
104
+ const nextDoc = Array.isArray(doc) ? merged : { ...doc, samples: merged };
105
+ writeFileSync(existingFile, stringifySampleDocument(existingFile, nextDoc));
106
+ return merged.length;
107
+ }
36
108
  function formatIdList(ids) {
37
109
  const shown = ids.slice(0, 5);
38
110
  const suffix = ids.length > shown.length ? ` +${ids.length - shown.length}` : '';
@@ -328,6 +400,12 @@ async function runSampleFromTraces(flags, lang) {
328
400
  }
329
401
  }
330
402
  async function runSample(args, flags, lang) {
403
+ // --append 目前只在单 skill 生成路径实现;batch / from-traces / fix 不处理它,
404
+ // 静默忽略会误导(用户以为在追加,实际没有)。提前互斥校验,明确报错。
405
+ if (flags.append && (flags.batch || flags['from-traces'] || flags.fix)) {
406
+ console.error(tCli('cli.gen.append_single_only', lang));
407
+ throw new CliExit(2);
408
+ }
331
409
  if (flags['from-traces']) {
332
410
  await runSampleFromTraces(flags, lang);
333
411
  return;
@@ -425,23 +503,23 @@ async function runSample(args, flags, lang) {
425
503
  }
426
504
  const skillContent = readFileSync(resolved.skillPath, 'utf-8');
427
505
  let outputPath;
506
+ let existingFile = null;
428
507
  if (!extname(resolved.samplesPath)) {
429
508
  const dir = resolved.samplesPath;
430
509
  if (existsSync(dir) && statSync(dir).isDirectory()) {
431
- const existing = readdirSync(dir).find((f) => /\.(json|ya?ml)$/i.test(f) && !/^(report|health|_)/i.test(f));
432
- if (existing) {
433
- console.error(tCli('cli.gen.samples_already_exists', lang));
434
- throw new CliExit(1);
435
- }
510
+ existingFile = pickAppendTargetFile(dir);
436
511
  }
437
- outputPath = join(dir, 'samples.json');
512
+ outputPath = existingFile ?? join(dir, 'samples.json');
438
513
  }
439
514
  else {
440
515
  outputPath = resolved.samplesPath;
441
- if (existsSync(outputPath)) {
442
- console.error(tCli('cli.gen.samples_already_exists', lang));
443
- throw new CliExit(1);
444
- }
516
+ if (existsSync(outputPath))
517
+ existingFile = outputPath;
518
+ }
519
+ // 已有用例文件:默认报错保护;--append 时追加(下面合并),不报错。
520
+ if (existingFile && !flags.append) {
521
+ console.error(tCli('cli.gen.samples_already_exists', lang));
522
+ throw new CliExit(1);
445
523
  }
446
524
  if (count !== undefined) {
447
525
  process.stderr.write(tCli('cli.gen.single_generating', lang, { count }));
@@ -451,12 +529,24 @@ async function runSample(args, flags, lang) {
451
529
  }
452
530
  try {
453
531
  const { samples, costUSD } = await generateSamples({ skillContent, count, model, focus, noMock: flags['no-mock'], executorName: flags.executor });
454
- mkdirSync(dirname(outputPath), { recursive: true });
455
- writeFileSync(outputPath, JSON.stringify(samples, null, 2));
456
532
  const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
457
- process.stderr.write(tCli('cli.gen.single_done', lang, {
458
- n: samples.length, path: outputPath, cost,
459
- }));
533
+ if (existingFile && flags.append) {
534
+ // 追加:读已有 合并(撞 id 去重)→ 保留原 json/yaml 格式与 wrapper 写回。
535
+ // 目录模式额外跨同目录其它 sample 文件去重,避免 eval 合并加载时撞 id 报错;
536
+ // 显式单文件路径无同目录合并语义,不需要。
537
+ const reserved = extname(resolved.samplesPath) ? undefined : collectDirSampleIds(dirname(existingFile));
538
+ const total = appendSamplesToFile(existingFile, samples, reserved);
539
+ process.stderr.write(tCli('cli.gen.append_done', lang, {
540
+ added: samples.length, total, path: existingFile, cost,
541
+ }));
542
+ }
543
+ else {
544
+ mkdirSync(dirname(outputPath), { recursive: true });
545
+ writeFileSync(outputPath, JSON.stringify(samples, null, 2));
546
+ process.stderr.write(tCli('cli.gen.single_done', lang, {
547
+ n: samples.length, path: outputPath, cost,
548
+ }));
549
+ }
460
550
  console.log(tCli('cli.gen.review_hint', lang));
461
551
  }
462
552
  catch (err) {
@@ -553,6 +643,13 @@ export default class Sample extends BaseCommand {
553
643
  en: 'Generation focus (NL hint). Steers LLM toward certain sample types.',
554
644
  }),
555
645
  }),
646
+ append: Flags.boolean({
647
+ description: bilingual({
648
+ zh: '在已有用例文件上追加新生成的用例(撞 sample_id 自动加后缀去重,保留原 json/yaml 格式)。仅单 skill 模式,不支持 --batch / --from-traces / --fix。不传则已有文件时报错保护。常配 --focus 补特定场景。',
649
+ en: 'Append newly generated samples to the existing samples file (colliding sample_id auto-suffixed, original json/yaml shape kept). Single-skill mode only; not supported with --batch / --from-traces / --fix. Without it, an existing file errors out. Often paired with --focus.',
650
+ }),
651
+ default: false,
652
+ }),
556
653
  'no-mock': Flags.boolean({
557
654
  description: bilingual({
558
655
  zh: '不生成 mocks,eval 时所有工具调用真实执行。',
@@ -155,6 +155,7 @@ export interface SampleFlags {
155
155
  executor?: string;
156
156
  'skill-dir': string;
157
157
  focus?: string;
158
+ append: boolean;
158
159
  'no-mock': boolean;
159
160
  fix: boolean;
160
161
  'reports-dir'?: string;
@@ -1,3 +1,3 @@
1
1
  import type { CliMessage } from './types.js';
2
- export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
2
+ export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.observe.observation_recorded' | 'cli.observe.production_gap' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
3
3
  export declare const commonDict: Record<CommonMessageKey, CliMessage>;
@@ -59,6 +59,14 @@ export const commonDict = {
59
59
  zh: '分析 JSON 已写入 output-dir;后续可用 omk observe 持续生成健康报告。',
60
60
  en: 'Analysis JSON written to output-dir; use omk observe to keep producing health reports.',
61
61
  },
62
+ 'cli.observe.observation_recorded': {
63
+ zh: '🔭 已为受管 skill「{name}」记录生产健康观测。\n',
64
+ en: '🔭 Recorded a production-health observation for managed skill "{name}".\n',
65
+ },
66
+ 'cli.observe.production_gap': {
67
+ zh: '⚠️ 受管 skill「{name}」线上检测到生产盲区({areas})。建议补充对应用例后重跑 omk eval —— observe 只提示,不改样本集。\n',
68
+ en: '⚠️ Managed skill "{name}" shows production gaps in real traffic ({areas}). Consider adding matching samples and re-running omk eval — observe only suggests, it does not edit the sample set.\n',
69
+ },
62
70
  'cli.studio.started': {
63
71
  zh: 'studio 已启动:{url}',
64
72
  en: 'Studio running at {url}',
@@ -1,3 +1,3 @@
1
1
  import type { CliMessage } from './types.js';
2
- export type GenMessageKey = 'cli.gen.skill_skipped_existing' | 'cli.gen.skill_generating' | 'cli.gen.skill_generating_auto' | 'cli.gen.skill_done' | 'cli.gen.skill_failed' | 'cli.gen.batch_none_needed' | 'cli.gen.batch_summary' | 'cli.gen.specify_skill_path' | 'cli.gen.samples_already_exists' | 'cli.gen.single_generating' | 'cli.gen.single_generating_auto' | 'cli.gen.single_done' | 'cli.gen.review_hint' | 'cli.gen.failed' | 'cli.gen.focus_applied';
2
+ export type GenMessageKey = 'cli.gen.skill_skipped_existing' | 'cli.gen.skill_generating' | 'cli.gen.skill_generating_auto' | 'cli.gen.skill_done' | 'cli.gen.skill_failed' | 'cli.gen.batch_none_needed' | 'cli.gen.batch_summary' | 'cli.gen.specify_skill_path' | 'cli.gen.samples_already_exists' | 'cli.gen.single_generating' | 'cli.gen.single_generating_auto' | 'cli.gen.single_done' | 'cli.gen.append_done' | 'cli.gen.append_single_only' | 'cli.gen.review_hint' | 'cli.gen.failed' | 'cli.gen.focus_applied';
3
3
  export declare const genDict: Record<GenMessageKey, CliMessage>;
@@ -47,6 +47,14 @@ export const genDict = {
47
47
  zh: '✅ 已生成 {n} 条用例 → {path}{cost}\n',
48
48
  en: '✅ Generated {n} samples → {path}{cost}\n',
49
49
  },
50
+ 'cli.gen.append_done': {
51
+ zh: '✅ 新增 {added} 条用例(撞 id 已自动改名),合并后共 {total} 条 → {path}{cost}\n',
52
+ en: '✅ Appended {added} samples (colliding ids auto-renamed), {total} total → {path}{cost}\n',
53
+ },
54
+ 'cli.gen.append_single_only': {
55
+ zh: '--append 目前仅支持单 skill 模式,不能与 --batch / --from-traces / --fix 同用。\n',
56
+ en: '--append currently supports single-skill mode only; it cannot be combined with --batch / --from-traces / --fix.\n',
57
+ },
50
58
  'cli.gen.review_hint': {
51
59
  zh: '\n请审查生成的评测用例后运行: omk eval',
52
60
  en: '\nReview the generated test cases, then run: omk eval',
@@ -1,3 +1,3 @@
1
1
  import type { CliMessage } from './types.js';
2
- export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.legend';
2
+ export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.production_gap_note' | 'cli.list.legend';
3
3
  export declare const listDict: Record<ListMessageKey, CliMessage>;
@@ -29,6 +29,10 @@ export const listDict = {
29
29
  zh: '✓ = 当前版本已按证据人工接受为 promoted(omk promote)。\n',
30
30
  en: '✓ = current version accepted as promoted on evidence (omk promote).\n',
31
31
  },
32
+ 'cli.list.production_gap_note': {
33
+ zh: '🔬 = observe 在线上检测到生产盲区(与生命周期无关的版本无关信号);建议补对应用例后重跑 omk eval。\n',
34
+ en: '🔬 = observe detected a production gap in real traffic (a version-agnostic signal, orthogonal to lifecycle); add matching samples and re-run omk eval.\n',
35
+ },
32
36
  'cli.list.legend': {
33
37
  zh: '证据列 = 当前有效 / 全部(历史含旧内容证据,供回滚)。\n',
34
38
  en: 'EVIDENCE column = current / total (history keeps old-content evidence for rollback).\n',
@@ -2,26 +2,35 @@ import { resolve } from 'node:path';
2
2
  import { createOverlayReportStore } from '../../server/report-store.js';
3
3
  import { projectReportsDir, globalReportsDir } from '../../eval-core/measurement-dirs.js';
4
4
  import { computeVerdict } from '../../eval-core/verdict.js';
5
- import { bootstrapDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES } from '../../eval-core/bootstrap.js';
5
+ import { bootstrapPairedDiffCI, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES } from '../../eval-core/bootstrap.js';
6
6
  import { resolveManagedDir, managedDir, loadAllManagedRecords, appendManagedEvidence, rebaselineManagedContentHash, buildEvidenceRef, probeSourceState, } from '../../managed/index.js';
7
7
  /**
8
8
  * round-bestRound vs round-0 的忠实 verdict:复刻 eval 管线(evaluation-reporting.ts)对两变体抽
9
- * per-sample composite → bootstrapDiffCI → computeVerdict,与 `omk eval --bootstrap` 同口径、同 α / 重采样数。
9
+ * per-sample composite → sample 配对 bootstrapPairedDiffCI → computeVerdict,与 `omk eval --bootstrap`
10
+ * 同口径、同 α / 重采样数。
10
11
  * 不自造门限:评委是否显著、是否 PROGRESS 全交给既有 computeVerdict。
11
12
  */
12
13
  function winnerVerdict(report, winnerVariant) {
13
14
  const baseline = 'round-0';
14
- const scoresOf = (v) => report.results
15
- .map((r) => r.variants[v])
16
- .filter((e) => !!e && typeof e.compositeScore === 'number' && e.compositeScore > 0)
17
- .map((e) => e.compositeScore);
18
- const ctrl = scoresOf(baseline);
19
- const treat = scoresOf(winnerVariant);
20
- const pairComparisons = ctrl.length >= 2 && treat.length >= 2
15
+ // sample **配对**(与 evaluation-reporting 主 A/B 同口径 —— 本函数职责就是复刻 eval 管线):baseline
16
+ // winner 在同一 sample 上都可测(composite > 0)才入对。同一 sample 两版分数正相关,配对 bootstrap 收紧 diff
17
+ // CI;独立重采样会高估方差、保守失功效。diff = b a = winner baseline。
18
+ const compositeOf = (v, r) => {
19
+ const e = r.variants[v];
20
+ return e && typeof e.compositeScore === 'number' && e.compositeScore > 0 ? e.compositeScore : undefined;
21
+ };
22
+ const pairs = [];
23
+ for (const r of report.results) {
24
+ const a = compositeOf(baseline, r);
25
+ const b = compositeOf(winnerVariant, r);
26
+ if (a !== undefined && b !== undefined)
27
+ pairs.push({ a, b });
28
+ }
29
+ const pairComparisons = pairs.length >= 2
21
30
  ? [{
22
31
  control: baseline,
23
32
  treatment: winnerVariant,
24
- diffBootstrapCI: bootstrapDiffCI(ctrl, treat, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES),
33
+ diffBootstrapCI: bootstrapPairedDiffCI(pairs, DEFAULT_BOOTSTRAP_ALPHA, DEFAULT_BOOTSTRAP_SAMPLES),
25
34
  }]
26
35
  : undefined;
27
36
  const slice = {