oh-my-knowledge 0.38.0 → 0.39.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.
@@ -584,6 +584,7 @@ omk sample [skillPath] [flags]
584
584
 
585
585
  **Flags:**
586
586
 
587
+ - `--append` `boolean`:在已有用例文件上追加新生成的用例(撞 sample_id 自动加后缀去重,保留原 json/yaml 格式)。仅单 skill 模式,不支持 --batch / --from-traces / --fix。不传则已有文件时报错保护。常配 --focus 补特定场景。
587
588
  - `--batch` `boolean`:批量模式:扫 --skill-dir 下所有缺 samples 的 skill,逐个生成。
588
589
  - `--count` `option`:生成用例条数。不传由 LLM 按 skill 类型自动决定。
589
590
  - `--executor` `option`:执行器名,默认 claude(同 omk eval / doctor / evolve)。指定 codex 等其它执行器时,记得连带传一个该执行器能识别的 --model。
@@ -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 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',
@@ -66,6 +66,7 @@ export function buildVariantConfig(artifact) {
66
66
  cwd: artifact.cwd || null,
67
67
  locator: artifact.locator,
68
68
  ref: artifact.ref,
69
+ ...(artifact.resolvedCommit ? { resolvedCommit: artifact.resolvedCommit } : {}),
69
70
  // propagate skill-isolation declaration so report.meta.skillIsolation
70
71
  // 能在 evaluation-reporting 阶段从 variantConfigs 提取 (avoid re-resolving artifacts).
71
72
  ...(artifact.allowedSkills !== undefined && { allowedSkills: artifact.allowedSkills }),
@@ -32,4 +32,9 @@ export interface LoadSamplesResult {
32
32
  * - `requires` from each file unioned together
33
33
  */
34
34
  export declare function loadSamples(samplesPath: string): LoadSamplesResult;
35
+ /** Pull `.json/.yaml/.yml` siblings out of a directory, skipping omk's own report/health
36
+ * artifacts and any underscore-prefixed file (the convention for "not a sample").
37
+ * Exported so `omk sample --append` picks its target with the same sorted/filtered order
38
+ * that directory-mode loading merges by (deterministic, predictable). */
39
+ export declare function listSampleFilesInDir(dir: string): string[];
35
40
  export declare function validateSamples(samples: Sample[]): void;
@@ -38,8 +38,10 @@ export function loadSamples(samplesPath) {
38
38
  };
39
39
  }
40
40
  /** Pull `.json/.yaml/.yml` siblings out of a directory, skipping omk's own report/health
41
- * artifacts and any underscore-prefixed file (the convention for "not a sample"). */
42
- function listSampleFilesInDir(dir) {
41
+ * artifacts and any underscore-prefixed file (the convention for "not a sample").
42
+ * Exported so `omk sample --append` picks its target with the same sorted/filtered order
43
+ * that directory-mode loading merges by (deterministic, predictable). */
44
+ export function listSampleFilesInDir(dir) {
43
45
  const RESERVED = /^(report|health|_)/i;
44
46
  return readdirSync(dir)
45
47
  .filter((f) => /\.(json|ya?ml)$/i.test(f))
@@ -5,6 +5,14 @@ export declare function gitShowFile(ref: string, filePath: string, cwd?: string)
5
5
  * 同 gitShowFile 用 `cat-file blob`:对目录会非零退出,不会把树清单字节当文件内容物化。
6
6
  */
7
7
  export declare function gitShowBytes(ref: string, filePath: string, cwd?: string): Buffer | null;
8
+ /**
9
+ * 把一个 ref(branch / tag / HEAD / 缩写或完整 SHA)解析到它指向的 commit SHA(#234/#236 还原坐标)。
10
+ * `^{commit}` 会把 annotated tag 也 peel 到 commit;`--verify --quiet` 解析不出时静默非零退出 → 落 null。
11
+ * 不存在 / 出错 → null(best-effort,绝不抛、不阻断 eval)。`<ref>` 物化内容时用的就是它,故这是被测字节的定点
12
+ * (与工作树是否 dirty 无关 —— 内容从 object DB 按 ref 取)。dash-ref(前缀 `-`)直接挡,不得被当 git 选项
13
+ * (rev-parse 的 rev 不能放 `--` 之后,故显式前置守卫,与 gitShowBytes 的 `--` 同口径 fail-closed)。
14
+ */
15
+ export declare function gitResolveCommit(ref: string, cwd?: string): string | null;
8
16
  export interface GitTreeEntry {
9
17
  /** git 文件模式:100644/100755=普通文件,120000=软链,160000=submodule。 */
10
18
  mode: string;
@@ -73,6 +73,24 @@ export function gitShowBytes(ref, filePath, cwd = process.cwd()) {
73
73
  return null;
74
74
  }
75
75
  }
76
+ /**
77
+ * 把一个 ref(branch / tag / HEAD / 缩写或完整 SHA)解析到它指向的 commit SHA(#234/#236 还原坐标)。
78
+ * `^{commit}` 会把 annotated tag 也 peel 到 commit;`--verify --quiet` 解析不出时静默非零退出 → 落 null。
79
+ * 不存在 / 出错 → null(best-effort,绝不抛、不阻断 eval)。`<ref>` 物化内容时用的就是它,故这是被测字节的定点
80
+ * (与工作树是否 dirty 无关 —— 内容从 object DB 按 ref 取)。dash-ref(前缀 `-`)直接挡,不得被当 git 选项
81
+ * (rev-parse 的 rev 不能放 `--` 之后,故显式前置守卫,与 gitShowBytes 的 `--` 同口径 fail-closed)。
82
+ */
83
+ export function gitResolveCommit(ref, cwd = process.cwd()) {
84
+ if (!ref || ref.startsWith('-'))
85
+ return null;
86
+ try {
87
+ const out = execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO }).trim();
88
+ return out || null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
76
94
  /**
77
95
  * 递归列出 `<ref>:<treePath>` 子树下的叶子条目(blob / 软链 / submodule)。tree 不存在返回 []。
78
96
  * 用 `-z`(NUL 分隔):git 不会对含换行 / 非 ASCII 的路径做 C-quote,路径原样可回喂 git show。
@@ -632,6 +650,9 @@ export function resolveArtifacts(skillDir, variants, opts = {}) {
632
650
  if (!resolved) {
633
651
  throw new Error(`skill not found in git ${ref}: ${name}.md or ${name}/SKILL.md`);
634
652
  }
653
+ // #234/#236:把 variant 的 ref 解析到实际 commit 当还原坐标。是 ref(可能 branch/tag/HEAD)而非进程
654
+ // cwd 的 HEAD —— 内容从 object DB 按这个 ref 物化,坐标必须对齐它(否则在别的分支跑会记错版本)。
655
+ const resolvedCommit = gitResolveCommit(ref, gitCtx.repoRoot) ?? undefined;
635
656
  if (resolved.isDir) {
636
657
  // git 目录-skill 忠实执行:物化整树到临时目录 → 落地内容寻址隔离副本 → executor cwd 锚副本,
637
658
  // agent 读得到 references/ 资产、资产成为真实运行时输入。整树指纹与 install 受管记录的
@@ -652,6 +673,7 @@ export function resolveArtifacts(skillDir, variants, opts = {}) {
652
673
  contentHash: isolated.contentHash,
653
674
  locator: name,
654
675
  ref,
676
+ ...(resolvedCommit ? { resolvedCommit } : {}),
655
677
  cwd: variantCwd,
656
678
  ...(isolated.execRoot ? { execRoot: isolated.execRoot } : {}),
657
679
  });
@@ -672,6 +694,7 @@ export function resolveArtifacts(skillDir, variants, opts = {}) {
672
694
  contentHash: hashBytes(skillMdBytes),
673
695
  locator: name,
674
696
  ref,
697
+ ...(resolvedCommit ? { resolvedCommit } : {}),
675
698
  cwd: variantCwd,
676
699
  });
677
700
  continue;
@@ -1,7 +1,8 @@
1
1
  import type { EvaluationReport, ManagedEvidenceRef } from '../types/index.js';
2
2
  /**
3
3
  * 为某个变体组装一条 evidence ref;变体无真实内容(baseline / no-skill / 缺 hash)→ null。
4
- * `verdict` 由调用方传入(CLI 已 computeVerdict,避免在此重算)。
4
+ * `verdict` 由调用方传入(CLI 已 computeVerdict,避免在此重算)。git 还原坐标从该 variant 的
5
+ * `resolvedCommit` 取(见 variantResolvedCommit),无则不记。
5
6
  */
6
7
  export declare function buildEvidenceRef(report: EvaluationReport, variant: string, verdict: string, recordedAt: string): ManagedEvidenceRef | null;
7
8
  export interface RecordedEvidence {
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { basename, dirname } from 'node:path';
29
29
  import { hashString } from '../eval-core/evaluation-reporting.js';
30
- import { loadAllManagedRecords, appendManagedEvidence, managedDir, resolveManagedDir } from './store.js';
30
+ import { loadAllManagedRecords, appendManagedEvidence, managedDir, resolveManagedDir, isShaLike } from './store.js';
31
31
  /** baseline / 无 skill 变体的 artifactHash 哨兵(见 report.ts artifactHashes 注释)——不产证据。 */
32
32
  const NO_SKILL = 'no-skill';
33
33
  /** 样本集覆盖摘要:report 的 sampleHashes 排序后取一个稳定 digest(同一样本集 ⇒ 同 hash)。
@@ -39,9 +39,20 @@ function sampleCoverage(report) {
39
39
  const entries = Object.entries(sh).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
40
40
  return { count: entries.length, hash: hashString(JSON.stringify(entries)) };
41
41
  }
42
+ /**
43
+ * 该版的 git 还原坐标(#234/#236):被测 variant 物化时 `<ref>` 解析出的实际 commit(resolveArtifacts 在
44
+ * 物化本地 git variant 时算好,经 `variantConfigs[].resolvedCommit` 透传)。**不是**进程 cwd 的 HEAD ——
45
+ * variant 内容从 object DB 按它自己的 ref 取,在别的分支 / 用显式旧 SHA 跑时 cwd HEAD 会是错版本。
46
+ * 远端 / file variant 无 resolvedCommit → 不记(诚实留空)。读到的值再过一道 SHA 形态守卫,脏报告不污染证据。
47
+ */
48
+ function variantResolvedCommit(report, variant) {
49
+ const cfg = report.meta?.variantConfigs?.find((c) => c.variant === variant);
50
+ return isShaLike(cfg?.resolvedCommit) ? cfg.resolvedCommit : undefined;
51
+ }
42
52
  /**
43
53
  * 为某个变体组装一条 evidence ref;变体无真实内容(baseline / no-skill / 缺 hash)→ null。
44
- * `verdict` 由调用方传入(CLI 已 computeVerdict,避免在此重算)。
54
+ * `verdict` 由调用方传入(CLI 已 computeVerdict,避免在此重算)。git 还原坐标从该 variant 的
55
+ * `resolvedCommit` 取(见 variantResolvedCommit),无则不记。
45
56
  */
46
57
  export function buildEvidenceRef(report, variant, verdict, recordedAt) {
47
58
  const contentHash = report.meta?.artifactHashes?.[variant];
@@ -49,6 +60,7 @@ export function buildEvidenceRef(report, variant, verdict, recordedAt) {
49
60
  return null;
50
61
  const meta = report.meta;
51
62
  const cov = sampleCoverage(report);
63
+ const gitCommit = variantResolvedCommit(report, variant);
52
64
  return {
53
65
  reportId: report.id,
54
66
  contentHash,
@@ -60,6 +72,7 @@ export function buildEvidenceRef(report, variant, verdict, recordedAt) {
60
72
  ...(meta.judgePromptHash ? { judgePromptHash: meta.judgePromptHash } : {}),
61
73
  ...(meta.debiasMode ? { debiasMode: meta.debiasMode } : {}),
62
74
  },
75
+ ...(gitCommit ? { gitCommit } : {}),
63
76
  };
64
77
  }
65
78
  /**
@@ -5,6 +5,7 @@
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
7
  export * from './list-view.js';
8
+ export * from './version-scores.js';
8
9
  export * from './list-query.js';
9
10
  export * from './source-probe.js';
10
11
  export * from './promote-gate.js';
@@ -5,6 +5,7 @@
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
7
  export * from './list-view.js';
8
+ export * from './version-scores.js';
8
9
  export * from './list-query.js';
9
10
  export * from './source-probe.js';
10
11
  export * from './promote-gate.js';
@@ -36,6 +36,12 @@ export interface ManagedListRow {
36
36
  judgePromptHash?: string;
37
37
  debiasMode?: Array<'length' | 'position'>;
38
38
  };
39
+ /** 当前 promoted 版本是否经 `--force` override 采用(只读审计标);非越门 / 已 rollback / 未采用 → undefined。
40
+ * override 的写仍只在 CLI(`promote --force`),Studio 只读展示 —— 见 spec §9(#238)。 */
41
+ override?: {
42
+ verdict: string;
43
+ overriddenBlocks?: string[];
44
+ };
39
45
  /** 最新当前证据的记录时间。 */
40
46
  recordedAt?: string;
41
47
  /** 当前有效证据数 / 全部证据数(含旧内容的历史证据)。 */
@@ -1,4 +1,4 @@
1
- import { deriveManagedState, isCurrentlyPromoted } from './store.js';
1
+ import { deriveManagedState, isCurrentlyPromoted, currentPromoteOverride } from './store.js';
2
2
  /** 当前有效证据(contentHash == record.contentHash)里 recordedAt 最新那条;无则 undefined。
3
3
  * list(展示最新 verdict)与 promote(门禁取证)共用同一口径——旧内容的证据不冒充当前。 */
4
4
  export function latestCurrentEvidence(record) {
@@ -35,6 +35,7 @@ export function buildManagedListRow(record, probe) {
35
35
  drifted = false;
36
36
  }
37
37
  const latest = latestCurrentEvidence(record);
38
+ const override = currentPromoteOverride(record);
38
39
  return {
39
40
  id: record.id,
40
41
  name: record.name,
@@ -47,6 +48,7 @@ export function buildManagedListRow(record, probe) {
47
48
  ...(latest?.verdict ? { latestVerdict: latest.verdict } : {}),
48
49
  ...(latest?.comparability ? { comparability: latest.comparability } : {}),
49
50
  ...(latest?.recordedAt ? { recordedAt: latest.recordedAt } : {}),
51
+ ...(override ? { override } : {}),
50
52
  currentEvidenceCount,
51
53
  totalEvidenceCount: record.evidence.length,
52
54
  distributionCount: record.distribution.length,
@@ -15,6 +15,9 @@ export declare function recordPath(dir: string, id: string): string;
15
15
  /** 稳定身份 = hash(kind, name)。源路径是可变属性、不进 id。kind 取自固定枚举(无 `|`),分隔可注入。 */
16
16
  export declare function managedRecordId(kind: ArtifactKind, name: string): string;
17
17
  export { hashArtifactSource, isDistributablePath, distributableCopyFilter } from '../inputs/content-hash.js';
18
+ /** git commit SHA 形态:7–64 位 hex。写入(evidence.ts)与读取校验共用同一判定,避免写读不对称
19
+ * (写时不校验、读时却要求 SHA → 自己写进去的值重载时被自己判脏)。 */
20
+ export declare function isShaLike(v: unknown): v is string;
18
21
  export declare function loadManagedRecord(dir: string, id: string): ManagedArtifactRecord | null;
19
22
  /** 读全部记录。项目目录空 → 兜底全局(镜像 observe inbox 的 project→global)。 */
20
23
  export declare function loadAllManagedRecords(dir?: string): ManagedArtifactRecord[];
@@ -65,6 +68,13 @@ export declare function rebaselineManagedContentHash(dir: string, recordId: stri
65
68
  * (数组追加序 = 事件序)。
66
69
  */
67
70
  export declare function isCurrentlyPromoted(record: ManagedArtifactRecord): boolean;
71
+ /**
72
+ * 当前 promoted 版本是否经 override(--force)采用 —— 返回该 override(verdict + 被绕过的门),否则 undefined。
73
+ * 仅当前内容最近一条决定是 promote 且带 override 才有值;rollback 之后(已撤销接受)返回 undefined。供 list /
74
+ * Studio **读时审计**:从总览一眼看出哪些当前采用是越门来的。override 的**写**仍只在 CLI(`promote --force`),
75
+ * Studio 不执行——见 evidence-gated-management.md §9(#238)。
76
+ */
77
+ export declare function currentPromoteOverride(record: ManagedArtifactRecord): ManagedDecision['override'];
68
78
  /**
69
79
  * 追加一条人工管理决定(append-only,promote/reject/rollback 走此路)。与 evidence 同样**不能走 upsert**
70
80
  * (`mergeManagedRecord` 刻意保留旧 decisions、丢弃 next.decisions),必须独立 load→push→原子重写。
@@ -35,6 +35,16 @@ function isStringField(v) {
35
35
  function isOptionalString(v) {
36
36
  return v === undefined || typeof v === 'string';
37
37
  }
38
+ /** git commit SHA 形态:7–64 位 hex。写入(evidence.ts)与读取校验共用同一判定,避免写读不对称
39
+ * (写时不校验、读时却要求 SHA → 自己写进去的值重载时被自己判脏)。 */
40
+ export function isShaLike(v) {
41
+ return typeof v === 'string' && /^[0-9a-f]{7,64}$/i.test(v);
42
+ }
43
+ /** 可选 git SHA 字段守卫(evidence.gitCommit):缺省或合法 SHA 形态。脏值会让 Studio 渲染
44
+ * `ev.gitCommit.slice()` 抛 TypeError(整页打不开),故读时按 SHA 形态收窄(脏值只剥字段,见下)。 */
45
+ function isOptionalSha(v) {
46
+ return v === undefined || isShaLike(v);
47
+ }
38
48
  // 受管记录可安装的 kind(managed 记录绝不是 baseline)。
39
49
  const MANAGED_KINDS = new Set(['skill', 'prompt', 'agent', 'workflow']);
40
50
  // 校验到**运行时实际收窄**的边界,不止「字段是 string」:记录文件是用户可手改、且可能随仓库分发(被
@@ -90,6 +100,11 @@ function isManagedArtifactRecord(value) {
90
100
  && !(Array.isArray(c.debiasMode) && c.debiasMode.every((m) => m === 'length' || m === 'position')))
91
101
  return false;
92
102
  }
103
+ // gitCommit 是纯展示的还原指针:脏值(非 SHA 形态)只剥掉该字段,不像测量关键字段那样判脏丢整条记录
104
+ // —— 一处装饰字段的 typo 不该让整条记录连同 evidence / decision 历史从 list / Studio 消失。剥后剩余
105
+ // 字段仍是合法 evidence,Studio 渲染 `ev.gitCommit.slice()` 也不再触雷(字段已不存在)。
106
+ if (!isOptionalSha(ev.gitCommit))
107
+ delete ev.gitCommit;
93
108
  return true;
94
109
  });
95
110
  const okDec = r.decisions.every((d) => {
@@ -273,6 +288,12 @@ export function rebaselineManagedContentHash(dir, recordId, newHash) {
273
288
  * (数组追加序 = 事件序)。
274
289
  */
275
290
  export function isCurrentlyPromoted(record) {
291
+ return latestPromoteRollbackForCurrent(record)?.decisionKind === 'promote';
292
+ }
293
+ /** 当前内容(contentHash === record.contentHash)最近一条 promote/rollback 决定;无则 undefined。latest-wins
294
+ * 口径见上(decidedAt 真实时刻、不可解析退字典序、并列取后出现的)。isCurrentlyPromoted 与
295
+ * currentPromoteOverride 共用,避免两处各写一遍 latest-wins 走样。 */
296
+ function latestPromoteRollbackForCurrent(record) {
276
297
  const ms = (s) => { const n = Date.parse(s); return Number.isNaN(n) ? null : n; };
277
298
  let latest;
278
299
  for (const d of record.decisions) {
@@ -290,7 +311,17 @@ export function isCurrentlyPromoted(record) {
290
311
  if (newer)
291
312
  latest = d;
292
313
  }
293
- return latest?.decisionKind === 'promote';
314
+ return latest;
315
+ }
316
+ /**
317
+ * 当前 promoted 版本是否经 override(--force)采用 —— 返回该 override(verdict + 被绕过的门),否则 undefined。
318
+ * 仅当前内容最近一条决定是 promote 且带 override 才有值;rollback 之后(已撤销接受)返回 undefined。供 list /
319
+ * Studio **读时审计**:从总览一眼看出哪些当前采用是越门来的。override 的**写**仍只在 CLI(`promote --force`),
320
+ * Studio 不执行——见 evidence-gated-management.md §9(#238)。
321
+ */
322
+ export function currentPromoteOverride(record) {
323
+ const d = latestPromoteRollbackForCurrent(record);
324
+ return d?.decisionKind === 'promote' ? d.override : undefined;
294
325
  }
295
326
  /**
296
327
  * 追加一条人工管理决定(append-only,promote/reject/rollback 走此路)。与 evidence 同样**不能走 upsert**
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 版本回归曲线的数据点构造(#236 Studio 决策史)。纯函数:report 由调用方按 evidence.reportId 读好传进来
3
+ * (Map,值可为 null = 报告已清 / 读不到),把受管记录的逐版证据拼成按时间从旧到新的 composite + 95%CI 序列。
4
+ *
5
+ * 测量味:每版的可比性签名(评委 prompt hash + debias + 样本集 hash)与「当前内容那版」一致才算 comparable;
6
+ * 换过评委 / 改过样本集的版本标 false —— 渲染据此把跨不可比的段画虚、点画空心,不糊一条误导的「在变好」线
7
+ * (spec evidence-gated-management.md §3「可比性必须可见」)。
8
+ */
9
+ import type { ManagedArtifactRecord } from '../types/index.js';
10
+ /** buildVersionScores 只需要报告里的这几样 —— 用结构化最小入参而非整个 ReportDocument,叶子可测、与 report schema 解耦。 */
11
+ export interface ReportScoreView {
12
+ meta?: {
13
+ artifactHashes?: Record<string, string>;
14
+ };
15
+ summary?: Record<string, {
16
+ avgCompositeScore?: number;
17
+ bootstrapCI?: {
18
+ low: number;
19
+ high: number;
20
+ };
21
+ } | undefined>;
22
+ }
23
+ export interface VersionScorePoint {
24
+ contentHash: string;
25
+ recordedAt: string;
26
+ composite: number;
27
+ ciLow: number;
28
+ ciHigh: number;
29
+ verdict?: string;
30
+ /** 与基线(当前内容那版)测量条件一致 → 可比;换过评委 / 改过样本集 → false。 */
31
+ comparable: boolean;
32
+ }
33
+ export declare function buildVersionScores(record: ManagedArtifactRecord, reportsById: Map<string, ReportScoreView | null>): VersionScorePoint[];
@@ -0,0 +1,70 @@
1
+ const ms = (s) => { const n = Date.parse(s); return Number.isNaN(n) ? null : n; };
2
+ /** 旧→新排序;omk 自写恒 UTC `Z`、字典序即时间序,但记录可手改,两端可解析才用真实时刻、否则退字典序。 */
3
+ function olderFirst(a, b) {
4
+ const pa = ms(a);
5
+ const pb = ms(b);
6
+ if (pa !== null && pb !== null)
7
+ return pa - pb;
8
+ return a < b ? -1 : a > b ? 1 : 0;
9
+ }
10
+ /**
11
+ * 一条证据的可比性签名:评委模板 + debias + 样本集,三者全一致才认为两次测量同口径、可直接比分。
12
+ * 缺评委指纹或样本集指纹 → 无从核对是否同口径,返回 null(= unknown);spec §6.1 明确 unknown 既不隐式
13
+ * 放行也不隐式拦截,故调用处把 null 一律按不可比渲染(空心点 + 虚线),不把「不知道能不能比」糊成实线趋势。
14
+ * debias 缺失退空串参与字符串比对(空 debias 是合法状态,非 unknown),只跟同样缺的版本算一致。
15
+ */
16
+ function comparabilitySig(ev) {
17
+ const judge = ev.comparability?.judgePromptHash;
18
+ const samples = ev.sampleCoverage?.hash;
19
+ if (!judge || !samples)
20
+ return null;
21
+ const debias = (ev.comparability?.debiasMode ?? []).slice().sort().join(',');
22
+ return `${judge}|${debias}|${samples}`;
23
+ }
24
+ export function buildVersionScores(record, reportsById) {
25
+ // 一版(contentHash)可能多条证据(重测)→ 按 contentHash 收敛,留 recordedAt 最新且能画出点的那条。
26
+ const byHash = new Map();
27
+ for (const ev of record.evidence) {
28
+ const report = reportsById.get(ev.reportId);
29
+ if (!report)
30
+ continue; // 报告已清 / 读不到 → 该点跳过,不臆造
31
+ const hashes = report.meta?.artifactHashes;
32
+ if (!hashes)
33
+ continue;
34
+ const variant = Object.keys(hashes).find((v) => hashes[v] === ev.contentHash);
35
+ if (!variant)
36
+ continue; // 匹配不到变体(旧 schema 不绑 / blind 未带哈)→ 跳过
37
+ const s = report.summary?.[variant];
38
+ const composite = s?.avgCompositeScore;
39
+ if (typeof composite !== 'number' || Number.isNaN(composite))
40
+ continue; // 无 composite → 无从画点
41
+ const ci = s?.bootstrapCI;
42
+ const ciLow = typeof ci?.low === 'number' ? ci.low : composite;
43
+ const ciHigh = typeof ci?.high === 'number' ? ci.high : composite;
44
+ const prev = byHash.get(ev.contentHash);
45
+ if (!prev || olderFirst(prev.ev.recordedAt, ev.recordedAt) < 0) {
46
+ byHash.set(ev.contentHash, { ev, composite, ciLow, ciHigh });
47
+ }
48
+ }
49
+ const entries = [...byHash.values()].sort((a, b) => olderFirst(a.ev.recordedAt, b.ev.recordedAt));
50
+ if (entries.length === 0)
51
+ return [];
52
+ // 基线 = 当前内容那版(没有就用最新一条),其余版本与之比对可比性。
53
+ const baselineEntry = byHash.get(record.contentHash) ?? entries[entries.length - 1];
54
+ const baselineSig = comparabilitySig(baselineEntry.ev);
55
+ return entries.map((en) => {
56
+ const sig = comparabilitySig(en.ev);
57
+ // 自身与基线都能核对(签名非 null)且签名一致才算可比;任一缺评委 / 样本指纹(null = unknown)→ 不可比。
58
+ // 基线本身 unknown → baselineSig 为 null → 全版本(含基线自身)不可比,宁可全空心也不把 unknown 糊成可比。
59
+ const comparable = sig !== null && baselineSig !== null && sig === baselineSig;
60
+ return {
61
+ contentHash: en.ev.contentHash,
62
+ recordedAt: en.ev.recordedAt,
63
+ composite: en.composite,
64
+ ciLow: en.ciLow,
65
+ ciHigh: en.ciHigh,
66
+ ...(en.ev.verdict ? { verdict: en.ev.verdict } : {}),
67
+ comparable,
68
+ };
69
+ });
70
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Lang } from '../types/index.js';
2
2
  import type { ManagedArtifactRecord } from '../types/index.js';
3
- import type { ManagedListRow } from '../managed/index.js';
4
- export declare function renderManagedHistory(record: ManagedArtifactRecord, lang: Lang): string;
3
+ import type { ManagedListRow, VersionScorePoint } from '../managed/index.js';
4
+ export declare function renderManagedHistory(record: ManagedArtifactRecord, lang: Lang, versionScores?: VersionScorePoint[]): string;
5
5
  export declare function renderManagedList(rows: ManagedListRow[], lang: Lang): string;
@@ -47,6 +47,7 @@ function buildTimeline(record) {
47
47
  events.push({
48
48
  at: ev.recordedAt, type: 'eval', contentHash: ev.contentHash, verdict: ev.verdict,
49
49
  reportId: ev.reportId, sampleCount: ev.sampleCoverage?.count, cliVersion: ev.comparability?.cliVersion,
50
+ ...(ev.gitCommit ? { gitCommit: ev.gitCommit } : {}),
50
51
  });
51
52
  }
52
53
  for (const d of record.decisions) {
@@ -62,7 +63,27 @@ function reportLink(reportId, lang) {
62
63
  return '';
63
64
  return `<a class="mh-link" href="/reports/${encodeURIComponent(reportId)}${langQuery(lang)}">${L(lang)('查看报告', 'report')} →</a>`;
64
65
  }
65
- function eventRow(ev, lang) {
66
+ /** 本地 git 源的仓内路径(`git:<ref>:<spec>` → spec),给 `git checkout <sha> -- <path>` 提示带上 `-- <path>`。
67
+ * 远端 / file 源 → undefined(本地 git 才有 cwd checkout 语义)。
68
+ * file-skill 的实际仓内文件是 `<spec>.md`(install 裸名 spec 经 classifyGitSkillRef 解到 `<spec>.md`),
69
+ * 故 file-skill 且 spec 未带 `.md` 时补上 —— 否则 `git checkout … -- review` 匹配不到 `review.md`。
70
+ * 已知局限:locator 的 spec 是**安装时 cwd 相对**的(不含 gitRelDir 前缀),在仓库子目录里 install 的 skill,
71
+ * 还原路径会缺该子目录前缀。彻底修需在记录上另存仓库根相对路径(与 drift 重解析的 cwd 相对语义解耦),留 follow-up。 */
72
+ function gitRestorePath(source) {
73
+ if (source.sourceKind !== 'git' || source.url)
74
+ return undefined;
75
+ const m = /^git:[^:]*:(.+)$/.exec(source.locator);
76
+ if (!m)
77
+ return undefined;
78
+ const spec = m[1];
79
+ return (!source.isDirectorySkill && !/\.md$/i.test(spec)) ? `${spec}.md` : spec;
80
+ }
81
+ /** POSIX 单引号包裹,内部 `'` 按 `'\''` 标准转义。还原提示是给用户复制粘贴的 shell 命令,git 路径可含
82
+ * 空格 / 分号 / 反引号 / `$()` 等元字符 —— e()(HTML escaping)挡不住 shell,不 quote 会被改写命令语义。 */
83
+ function shellQuote(s) {
84
+ return `'${s.replace(/'/g, "'\\''")}'`;
85
+ }
86
+ function eventRow(ev, lang, restorePath) {
66
87
  const t = L(lang);
67
88
  const typeLabel = {
68
89
  install: t('安装纳管', 'Installed'),
@@ -90,6 +111,15 @@ function eventRow(ev, lang) {
90
111
  detail.push(reportLink(ev.reportId, lang));
91
112
  if (ev.reason)
92
113
  detail.push(`<span class="mh-reason">「${e(ev.reason)}」</span>`);
114
+ // #234/#236 还原指针:这一版有 git 坐标 + 能解析出仓内路径 → 给现成的 `git checkout <sha> -- <path>` 把
115
+ // 该路径还原进工作树(字节级还原交给 git)。必须带 `-- <path>`:不带 pathspec 的 `git checkout <sha>` 是切
116
+ // detached HEAD、整棵工作树被换,语义完全不同且危险 —— 故解析不出路径时干脆不显,不退化成那条命令。显
117
+ // full SHA(精确坐标、无歧义);路径 shell-quote 防注入(见 shellQuote)。
118
+ // 注:对目录-skill,checkout 还原该版的跟踪文件,但不会删除其后新增的文件 —— git pathspec 的固有语义。
119
+ if (ev.gitCommit && restorePath) {
120
+ const cmd = `git checkout ${ev.gitCommit} -- ${shellQuote(restorePath)}`;
121
+ detail.push(`<span class="mh-detail-item mh-restore">${t('还原', 'restore')} <code>${e(cmd)}</code></span>`);
122
+ }
93
123
  return `<li class="mh-event mh-event--${ev.type}">
94
124
  <span class="mh-time">${e(fmtLocalTime(ev.at))}</span>
95
125
  <span class="mh-marker"><span class="mh-dot mh-dot--${ev.type}"></span></span>
@@ -103,10 +133,66 @@ function versionHeader(hash, isCurrent, lang) {
103
133
  const cur = isCurrent ? `<span class="mh-vcur">${L(lang)('当前', 'current')}</span>` : '';
104
134
  return `<li class="mh-vhead"><span class="mh-vhead-label">${L(lang)('版本', 'version')}</span><code class="mh-hash">${e(shortHash(hash))}</code>${cur}</li>`;
105
135
  }
106
- export function renderManagedHistory(record, lang) {
136
+ /** 版本回归曲线( SVG,确定性、可 snapshot):每版 composite 均值 + 95%CI 竖须,按时间从旧到新。
137
+ * 不可比的版本(换过评委 / 改过样本集,或缺指纹无法核对)点画空心、连线画虚 —— 不糊一条误导的「在变好」线(spec §3)。
138
+ * 数据由 buildVersionScores 算好;少于 2 个点不画(单点无趋势可言)。composite 量纲不定,y 轴按数据自适应。 */
139
+ function renderVersionCurve(points, lang) {
140
+ if (points.length < 2)
141
+ return '';
142
+ const t = L(lang);
143
+ const width = 620, height = 250, padL = 46, padR = 16, padT = 16, padB = 46;
144
+ const plotW = width - padL - padR;
145
+ const plotH = height - padT - padB;
146
+ const n = points.length;
147
+ let yLo = Math.min(...points.map((p) => p.ciLow));
148
+ let yHi = Math.max(...points.map((p) => p.ciHigh));
149
+ if (yHi - yLo < 1e-9) {
150
+ yLo -= 0.5;
151
+ yHi += 0.5;
152
+ } // 全相等退化:给个最小跨度,免除零。
153
+ const pad = (yHi - yLo) * 0.08;
154
+ yLo -= pad;
155
+ yHi += pad;
156
+ const xAt = (i) => padL + (plotW * i) / (n - 1);
157
+ const yAt = (v) => padT + plotH - (plotH * (v - yLo)) / (yHi - yLo);
158
+ const parts = [];
159
+ // 连线逐段;任一端不可比 → 虚线,提示别把跨不可比的差值读作进步 / 回退。
160
+ for (let i = 1; i < n; i++) {
161
+ const a = points[i - 1], b = points[i];
162
+ const dash = (!a.comparable || !b.comparable) ? ' stroke-dasharray="4 3"' : '';
163
+ parts.push(`<line x1="${xAt(i - 1).toFixed(1)}" y1="${yAt(a.composite).toFixed(1)}" x2="${xAt(i).toFixed(1)}" y2="${yAt(b.composite).toFixed(1)}" stroke="var(--accent)" stroke-width="2"${dash} />`);
164
+ }
165
+ // 每点:CI 竖须 + 上下端帽 + 点(可比实心 / 不可比空心)+ 短 hash 轴标。
166
+ points.forEach((p, i) => {
167
+ const x = xAt(i), yL = yAt(p.ciLow), yH = yAt(p.ciHigh), yM = yAt(p.composite);
168
+ parts.push(`<line x1="${x.toFixed(1)}" y1="${yH.toFixed(1)}" x2="${x.toFixed(1)}" y2="${yL.toFixed(1)}" stroke="var(--accent)" stroke-width="1" stroke-opacity="0.5" />`);
169
+ parts.push(`<line x1="${(x - 3).toFixed(1)}" y1="${yH.toFixed(1)}" x2="${(x + 3).toFixed(1)}" y2="${yH.toFixed(1)}" stroke="var(--accent)" stroke-width="1" stroke-opacity="0.5" />`);
170
+ parts.push(`<line x1="${(x - 3).toFixed(1)}" y1="${yL.toFixed(1)}" x2="${(x + 3).toFixed(1)}" y2="${yL.toFixed(1)}" stroke="var(--accent)" stroke-width="1" stroke-opacity="0.5" />`);
171
+ parts.push(p.comparable
172
+ ? `<circle cx="${x.toFixed(1)}" cy="${yM.toFixed(1)}" r="4" fill="var(--accent)" />`
173
+ : `<circle cx="${x.toFixed(1)}" cy="${yM.toFixed(1)}" r="4" fill="var(--bg-surface)" stroke="var(--accent)" stroke-width="1.5" />`);
174
+ parts.push(`<text x="${x.toFixed(1)}" y="${height - padB + 14}" font-size="9" text-anchor="middle" fill="var(--text-muted)">${e(p.contentHash.slice(0, 7))}</text>`);
175
+ });
176
+ const yTicks = [0, 0.5, 1].map((f) => {
177
+ const v = yLo + (yHi - yLo) * f;
178
+ const y = yAt(v);
179
+ return `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${width - padR}" y2="${y.toFixed(1)}" stroke="var(--border)" stroke-width="0.5" /><text x="${padL - 6}" y="${(y + 3).toFixed(1)}" font-size="10" text-anchor="end" fill="var(--text-muted)">${v.toFixed(2)}</text>`;
180
+ }).join('');
181
+ const anyIncomparable = points.some((p) => !p.comparable);
182
+ const note = anyIncomparable
183
+ ? t('空心点 = 测量条件不同或无法核对(换过评委 / 改过样本集 / 缺指纹),与当前版不可比;虚线段别直接读作进步或回退。', 'Hollow dots = measured under a different or unverifiable instrument (judge / sample set changed, or fingerprint missing) than the current version, not comparable; do not read dashed segments as progress or regression.')
184
+ : t('每个版本的 composite 均值与 95% 置信区间,按时间从旧到新。', 'Composite mean and 95% CI per version, oldest to newest.');
185
+ return `<section class="mh-curve">
186
+ <h2 class="mh-curve-title">${t('版本回归曲线', 'Version regression')}</h2>
187
+ <svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" class="mh-curve-svg">${yTicks}${parts.join('')}</svg>
188
+ <p class="mh-curve-note">${note}</p>
189
+ </section>`;
190
+ }
191
+ export function renderManagedHistory(record, lang, versionScores = []) {
107
192
  const t = L(lang);
108
193
  const events = buildTimeline(record);
109
194
  // 倒序遍历:内容版本(contentHash)变化处插版本段头;install 等无 hash 事件不重置分段。
195
+ const restorePath = gitRestorePath(record.source);
110
196
  const rows = [];
111
197
  let prevHash;
112
198
  for (const ev of events) {
@@ -114,7 +200,7 @@ export function renderManagedHistory(record, lang) {
114
200
  rows.push(versionHeader(ev.contentHash, ev.contentHash === record.contentHash, lang));
115
201
  prevHash = ev.contentHash;
116
202
  }
117
- rows.push(eventRow(ev, lang));
203
+ rows.push(eventRow(ev, lang, restorePath));
118
204
  }
119
205
  const meta = [
120
206
  record.kind,
@@ -129,6 +215,7 @@ export function renderManagedHistory(record, lang) {
129
215
  <h1 class="mh-name">${e(record.name)}</h1>
130
216
  <div class="mh-meta">${meta}</div>
131
217
  </header>
218
+ ${renderVersionCurve(versionScores, lang)}
132
219
  <ol class="mh-timeline">${rows.join('')}</ol>
133
220
  </main>
134
221
  <style>${MANAGED_CSS}</style>`;
@@ -155,6 +242,16 @@ function stateMeta(state, lang) {
155
242
  const [label, tip] = lang === 'zh' ? m.zh : m.en;
156
243
  return { label, tip };
157
244
  }
245
+ /** 列表只读审计标:当前采用版本是越门(`--force`)来的就标出来,tooltip 列被绕过的门。复用详情时间线的
246
+ * `.mh-override` 样式。override 的写仍只在 CLI —— Studio 只读(spec §9 / #238)。 */
247
+ function overrideBadge(row, lang) {
248
+ if (!row.override)
249
+ return '';
250
+ const t = L(lang);
251
+ const blocks = row.override.overriddenBlocks?.length ? row.override.overriddenBlocks.join(' / ') : '';
252
+ const tip = blocks ? t(`越门采用,绕过:${blocks}`, `force-promoted, waved: ${blocks}`) : t('越门采用', 'force-promoted');
253
+ return ` <span class="mh-override" title="${e(tip)}">${t('越门', 'override')}</span>`;
254
+ }
158
255
  function listRow(row, lang) {
159
256
  const t = L(lang);
160
257
  const st = stateMeta(row.state, lang);
@@ -164,7 +261,7 @@ function listRow(row, lang) {
164
261
  <span class="mh-row-state" title="${e(st.tip)}"><span class="mh-dot mh-dot--${stateBand(row.state)}"></span>${e(st.label)}${mark}</span>
165
262
  <span class="mh-row-name">${e(row.name)}</span>
166
263
  <span class="mh-row-kind">${e(row.kind)}</span>
167
- <span class="mh-row-verdict">${row.latestVerdict ? verdictBadge(row.latestVerdict, lang) : '—'}</span>
264
+ <span class="mh-row-verdict">${row.latestVerdict ? verdictBadge(row.latestVerdict, lang) : '—'}${overrideBadge(row, lang)}</span>
168
265
  <span class="mh-row-ev">${row.currentEvidenceCount}/${row.totalEvidenceCount}</span>
169
266
  <span class="mh-row-src" title="${e(row.sourceLabel)}">${e(row.sourceLabel)}</span>
170
267
  </a>`;
@@ -187,6 +284,7 @@ export function renderManagedList(rows, lang) {
187
284
  <span class="mh-legend-item"><span class="mh-dot mh-dot--red"></span>${t('已漂移 ⚠️:源变了、需重跑 omk eval', 'Drifted ⚠️ = source changed, re-run omk eval')}</span>
188
285
  <span class="mh-legend-item">${t('? 源未核(不可达 / 拒读,漂移待定)', '? = source unverified (unreachable / refused)')}</span>
189
286
  <span class="mh-legend-item">${t('证据列:当前有效 / 全部历史(旧证据留作回滚)', 'Evidence = current / total (old evidence kept for rollback)')}</span>
287
+ <span class="mh-legend-item"><span class="mh-override">${t('越门', 'override')}</span>${t(' 当前采用是 --force 越门来的(决定人由命令行记录)', ' = current version force-promoted via --force (actor recorded by CLI)')}</span>
190
288
  </div>`;
191
289
  const body = `<main class="mh-main">
192
290
  <header class="mh-hero">
@@ -210,6 +308,12 @@ const MANAGED_CSS = `
210
308
  .mh-meta{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px;color:var(--text-muted);font-size:12px}
211
309
  .mh-meta span{display:inline-flex;align-items:center;gap:4px;font-variant-numeric:tabular-nums}
212
310
 
311
+ /* ── 版本回归曲线 ── */
312
+ .mh-curve{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);padding:16px 20px;margin-bottom:18px}
313
+ .mh-curve-title{margin:0 0 4px;font-size:15px;font-weight:700;color:var(--text-primary);letter-spacing:-.2px}
314
+ .mh-curve-svg{width:100%;max-width:620px;height:auto;display:block;margin:6px 0}
315
+ .mh-curve-note{font-size:12px;color:var(--text-muted);margin:6px 0 0}
316
+
213
317
  /* ── 时间线 ── */
214
318
  .mh-timeline{list-style:none;padding:0;margin:0;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);overflow:hidden}
215
319
  .mh-vhead{display:flex;align-items:center;gap:8px;padding:10px 20px;background:var(--bg-elevated);border-top:1px solid var(--border);font-size:12px;color:var(--text-secondary)}
@@ -232,6 +336,7 @@ const MANAGED_CSS = `
232
336
  .mh-override{font-size:11px;font-weight:600;color:var(--yellow);background:var(--yellow-bg);padding:1px 8px;border-radius:9px}
233
337
  .mh-badge-raw{font-size:11.5px;color:var(--text-secondary);background:var(--bg-soft);padding:1px 8px;border-radius:9px}
234
338
  .mh-detail{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:4px;font-size:12px;color:var(--text-muted)}
339
+ .mh-restore code{font-family:"SF Mono",Menlo,monospace;font-size:11.5px;color:var(--text-secondary);background:var(--bg-soft);padding:1px 6px;border-radius:5px;user-select:all}
235
340
  .mh-reason{color:var(--text-secondary);font-style:italic}
236
341
  .mh-link{color:var(--accent);text-decoration:none}
237
342
  .mh-link:hover{text-decoration:underline}
@@ -9,7 +9,7 @@ import { renderDoctorDetail } from '../renderer/doctor-detail-renderer.js';
9
9
  import { assessHealth } from '../renderer/skill-detail-renderer.js';
10
10
  import { renderObservationInboxPage } from '../renderer/observation-inbox-renderer.js';
11
11
  import { DEFAULT_LANG, t, layout } from '../renderer/layout.js';
12
- import { loadAllManagedRecords, resolveManagedDir, managedDir as projectManagedDir, listManagedRows } from '../managed/index.js';
12
+ import { loadAllManagedRecords, resolveManagedDir, managedDir as projectManagedDir, listManagedRows, buildVersionScores } from '../managed/index.js';
13
13
  import { renderManagedList, renderManagedHistory } from '../renderer/managed-history-renderer.js';
14
14
  import { DEFAULT_JOBS_DIR } from '../eval-core/default-dirs.js';
15
15
  import { resolveObserveHealthDir, projectObserveHealthDir, resolveDoctorsDir, projectDoctorsDir, projectReportsDir, globalReportsDir } from '../eval-core/measurement-dirs.js';
@@ -706,8 +706,17 @@ export function createReportServer({ port, host: hostOption, reportsDir, analyse
706
706
  res.end(lang === 'en' ? 'managed record not found' : '受管记录不存在');
707
707
  return;
708
708
  }
709
+ // 版本回归曲线要每版的 composite/CI —— 按 evidence.reportId 把报告读出来(去重),读不到的点
710
+ // buildVersionScores 自会跳过。曲线数据在路由侧算好,renderManagedHistory 保持纯函数、可 snapshot。
711
+ const reportsById = new Map();
712
+ for (const ev of record.evidence) {
713
+ // 按「打分视角」结构化看报告:受管证据指向的是单次 eval 报告(带 artifactHashes / summary);
714
+ // 万一是 batch 报告,meta 无 artifactHashes → buildVersionScores 自会跳过,不会误画。
715
+ if (!reportsById.has(ev.reportId))
716
+ reportsById.set(ev.reportId, (await reportStore.get(ev.reportId)));
717
+ }
709
718
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
710
- res.end(renderManagedHistory(record, lang));
719
+ res.end(renderManagedHistory(record, lang, buildVersionScores(record, reportsById)));
711
720
  return;
712
721
  }
713
722
  if (path === '/observe-inbox') {
@@ -139,6 +139,7 @@ export interface Artifact {
139
139
  contentHash?: string;
140
140
  locator?: string;
141
141
  ref?: string;
142
+ resolvedCommit?: string;
142
143
  cwd?: string;
143
144
  skillRoot?: string;
144
145
  execRoot?: string;
@@ -160,6 +161,7 @@ export interface VariantConfig {
160
161
  cwd: string | null;
161
162
  locator?: string;
162
163
  ref?: string;
164
+ resolvedCommit?: string;
163
165
  allowedSkills?: string[];
164
166
  }
165
167
  /** 远端 git 源的结构化引用 —— url/ref/spec 分字段,永不拼成单串再 split(避开 parseGitInput 的 `:`
@@ -50,6 +50,13 @@ export interface ManagedEvidenceRef {
50
50
  judgePromptHash?: string;
51
51
  debiasMode?: Array<'length' | 'position'>;
52
52
  };
53
+ /** §7 #234/#236:这一版被测内容所在的 git commit(full SHA)= 评测时该 variant 的 git ref 解析出的 commit
54
+ * (resolveArtifacts 物化时 `git rev-parse <ref>^{commit}`),作每版「还原坐标」指针 —— list / Studio 据此给
55
+ * `git checkout <sha> -- <path>` 把源带回这一版(字节级还原由 git 做,omk 不存版本字节)。是 variant 自己的
56
+ * ref(可能 branch/tag/HEAD/旧 SHA)而非进程 cwd 的 HEAD —— 内容从 object DB 按 ref 取,故与工作树 dirty
57
+ * 与否无关。只对本地 git variant 记(远端源还原是重装 fetch-pin SHA;file 源无 git 坐标)。缺失 = 无坐标可
58
+ * 还原,诚实留空,不臆造。 */
59
+ gitCommit?: string;
53
60
  }
54
61
  export type ManagedDecisionKind = 'promote' | 'reject' | 'rollback';
55
62
  /** 一次人工管理决定。install 时为空,promote/reject/rollback 追加(append-only 事件流)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-knowledge",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
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",
@@ -92,11 +92,11 @@
92
92
  "license": "MIT",
93
93
  "dependencies": {
94
94
  "@anthropic-ai/claude-agent-sdk": "^0.3.143",
95
- "@anthropic-ai/sdk": "^0.102.0",
95
+ "@anthropic-ai/sdk": "^0.104.1",
96
96
  "@inquirer/prompts": "^8.4.3",
97
97
  "@modelcontextprotocol/sdk": "^1.29.0",
98
98
  "@oclif/core": "^4",
99
- "@openai/codex-sdk": "0.137.0",
99
+ "@openai/codex-sdk": "0.139.0",
100
100
  "ajv": "^8.18.0",
101
101
  "chart.js": "^4.5.1",
102
102
  "js-yaml": "^4.1.1",