oh-my-knowledge 0.35.0 → 0.36.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 (44) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/assets/agent-skills/omk/SKILL.md +2 -2
  4. package/dist/assets/agent-skills/omk/references/commands.md +36 -0
  5. package/dist/authoring/evolver.js +1 -1
  6. package/dist/cli/commands/doctor.js +2 -1
  7. package/dist/cli/commands/eval/index.js +7 -6
  8. package/dist/cli/commands/list.d.ts +46 -0
  9. package/dist/cli/commands/list.js +243 -0
  10. package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
  11. package/dist/cli/lib/i18n-dict/list.js +32 -0
  12. package/dist/cli/lib/i18n-dict.d.ts +2 -1
  13. package/dist/cli/lib/i18n-dict.js +2 -0
  14. package/dist/cli/lib/run-tally.js +1 -1
  15. package/dist/cli/lib/shared.js +1 -1
  16. package/dist/doctor/index.js +1 -1
  17. package/dist/eval-core/evaluation-reporting.d.ts +1 -0
  18. package/dist/eval-core/evaluation-reporting.js +6 -4
  19. package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
  20. package/dist/eval-workflows/run-evaluation.js +2 -2
  21. package/dist/inputs/skill-loader.js +6 -3
  22. package/dist/managed/index.d.ts +1 -0
  23. package/dist/managed/index.js +1 -0
  24. package/dist/managed/list-view.d.ts +55 -0
  25. package/dist/managed/list-view.js +66 -0
  26. package/dist/managed/store.js +45 -8
  27. package/dist/observability/experience.d.ts +2 -0
  28. package/dist/observability/experience.js +31 -2
  29. package/dist/observability/inbox.js +31 -5
  30. package/dist/observability/review-state.js +22 -11
  31. package/dist/observability/soft-standards/llm-extractor.js +3 -3
  32. package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
  33. package/dist/observability/soft-standards/skill-standards-store.js +77 -14
  34. package/dist/observability/soft-standards/types.d.ts +2 -2
  35. package/dist/renderer/html-renderer.js +3 -3
  36. package/dist/server/report-server.js +1 -1
  37. package/dist/server/report-store.d.ts +1 -1
  38. package/dist/server/report-store.js +16 -15
  39. package/dist/server/skill-index.js +5 -4
  40. package/dist/types/doctor.d.ts +2 -2
  41. package/dist/types/doctor.js +1 -1
  42. package/dist/types/observability.d.ts +6 -6
  43. package/dist/types/report.d.ts +9 -4
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -101,7 +101,7 @@ RAG-specific evals: see RAGAS (separate niche, complementary to omk). Full compa
101
101
  | **Knowledge-gap detection** | severity-weighted signals quantify risk exposure instead of claiming completeness |
102
102
  | **Construct-validity isolation** | `--strict-baseline` (default ON) cuts three contamination channels so baseline doesn't silently see the skill it's being compared against |
103
103
  | **Git & remote sources** | install / eval from a local git ref or a remote git URL (`--git-url`); directory-skills run in a content-addressed **isolated copy** so `references/` assets are real measured input, not just `SKILL.md` |
104
- | **Evidence-gated management** | `omk install` registers a managed record; `omk eval` auto-writes evidence bound by content fingerprint, moving a skill `installed → measurable`. [spec →](docs/specs/evidence-gated-management.md) |
104
+ | **Evidence-gated management** | `omk install` registers a managed record; `omk eval` auto-writes evidence bound by content fingerprint, moving a skill `installed → measurable`; `omk list` surfaces each managed skill's status (installed / measurable / stale). [spec →](docs/specs/evidence-gated-management.md) |
105
105
  | **Sample design science** | sample schema with `capability` / `difficulty` / `construct` / `provenance` metadata (HF Dataset Cards style); studio surfaces coverage breakdown plus `rubric_clarity_low` / `capability_thin` flags. [docs/specs/sample-design-spec.md](docs/specs/sample-design-spec.md) |
106
106
  | **Multi-judge ensemble** | `--judge-models claude:opus,openai:gpt-4o` cross-vendor scoring + agreement metrics |
107
107
  | **Blind A/B** | `--blind` hides variant names; HTML report has a reveal button |
package/README.zh.md CHANGED
@@ -101,7 +101,7 @@ RAG 专项评测请看 RAGAS(独立 niche,跟 omk 互补)。完整对比
101
101
  | **知识缺口识别** | 严重度加权的信号量化风险敞口,不宣称完备性 |
102
102
  | **用例隔离 (construct validity)** | `--strict-baseline`(默认开)三堵 baseline 拿到被测 skill 的污染路径 |
103
103
  | **Git / 远端源** | install / eval 支持本地 git ref 或远端 git URL(`--git-url`);目录-skill 在内容寻址**隔离副本**里执行,`references/` 资产是真实测量输入,不只是 `SKILL.md` |
104
- | **证据门控管理** | `omk install` 登记受管记录;`omk eval` 按内容指纹自动写入证据,把 skill 从 `installed` 推到 `measurable`。[规范 →](docs/zh/specs/evidence-gated-management.md) |
104
+ | **证据门控管理** | `omk install` 登记受管记录;`omk eval` 按内容指纹自动写入证据,把 skill 从 `installed` 推到 `measurable`;`omk list` 查看各受管 skill 的状态(installed / measurable / stale)。[规范 →](docs/zh/specs/evidence-gated-management.md) |
105
105
  | **用例设计科学性** | Sample schema 加 `capability` / `difficulty` / `construct` / `provenance` 元数据字段(HF Dataset Cards 风),studio 输出 coverage 分桶 + `rubric_clarity_low` / `capability_thin` issue。[docs/zh/specs/sample-design-spec.md](docs/zh/specs/sample-design-spec.md) |
106
106
  | **多评委 ensemble** | `--judge-models claude:opus,openai:gpt-4o` 跨厂商评分 + agreement 度量 |
107
107
  | **盲测 A/B** | `--blind` 隐藏变体名称,HTML 报告有揭晓按钮 |
@@ -4,7 +4,7 @@ description: |
4
4
  oh-my-knowledge 知识载体评测工具的智能代理。评测 skill(系统提示词)质量,对比不同版本效果,自动迭代改进。
5
5
  Use when: 用户提到"评测"、"测评"、"eval"、"benchmark"、"对比 skill"、"改进 skill"、"evolve"、"生成测试用例"、"gen-samples"、"omk"。
6
6
  user-invocable: true
7
- argument-hint: "<doctor|eval|evolve|init|install|observe|sample|studio> [options]"
7
+ argument-hint: "<doctor|eval|evolve|init|install|list|observe|sample|studio> [options]"
8
8
  ---
9
9
 
10
10
  # OMK — 知识载体评测
@@ -19,7 +19,7 @@ argument-hint: "<doctor|eval|evolve|init|install|observe|sample|studio> [options
19
19
  npm i oh-my-knowledge -g
20
20
  ```
21
21
 
22
- omk CLI 顶层命令包括:`init` / `install` / `doctor` / `eval` / `observe` / `evolve` / `sample` / `studio`。没有 `bench` / `improve` / `gen-samples` 这些旧子命令名 —— 如果你在历史 SKILL / 文档里看到了,那是 v0.30 命令树重构之前的写法。
22
+ omk CLI 顶层命令包括:`init` / `install` / `list` / `doctor` / `eval` / `observe` / `evolve` / `sample` / `studio`。没有 `bench` / `improve` / `gen-samples` 这些旧子命令名 —— 如果你在历史 SKILL / 文档里看到了,那是 v0.30 命令树重构之前的写法。
23
23
 
24
24
  ## 第二步:理解用户意图
25
25
 
@@ -348,6 +348,42 @@ omk install git:main:skills/review
348
348
  omk install --git-url https://github.com/org/repo.git --git-ref v1.0.0 skills/review
349
349
  ```
350
350
 
351
+ ## omk list
352
+
353
+ 列出受管 skill 及其证据状态:生命周期(installed / measurable / stale)、最新 verdict、证据数、源。
354
+
355
+ **用法:**
356
+
357
+ ```bash
358
+ omk list [flags]
359
+ ```
360
+
361
+ **Flags:**
362
+
363
+ - `--global` `boolean`:看全局受管目录(~/.oh-my-knowledge/managed)而非项目 .omk/managed
364
+ - `--json` `boolean`:输出 JSON(含完整可比性 marker),供脚本消费
365
+ - `--lang` `option` (默认 `zh`):输出语言 zh|en,优先级 CLI > OMK_LANG env > zh。
366
+
367
+ **示例:**
368
+
369
+ > 列出当前项目的受管 skill
370
+
371
+ ```bash
372
+ omk list
373
+ ```
374
+
375
+ > 列出全局受管 skill
376
+
377
+ ```bash
378
+ omk list --global
379
+ ```
380
+
381
+ > 机器可读 JSON 输出
382
+
383
+ ```bash
384
+ omk list --json
385
+ ```
386
+
351
387
  ## omk observe
352
388
 
353
389
  分析 sessions 目录的 skill 调用健康度(默认行为)。子命令:ingest / inbox / show。
@@ -546,7 +546,7 @@ export function mergeEvolveReports(roundReports, skillName, totalCostUSD, sample
546
546
  }
547
547
  const runId = `evolve-${skillName}-${generateRunId([skillName]).split('-').slice(-2).join('-')}`;
548
548
  const report = {
549
- reportKind: 'evaluation',
549
+ kind: 'evaluation',
550
550
  id: runId,
551
551
  meta: {
552
552
  ...firstReport.meta,
@@ -300,7 +300,8 @@ export function pruneDoctorHistory(dir, skillName, maxKeep) {
300
300
  continue;
301
301
  try {
302
302
  const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
303
- if (data?.reportKind !== 'doctor' || !Array.isArray(data.skills) || data.skills.length !== 1)
303
+ const kind = data?.kind === 'doctor' ? data.kind : null;
304
+ if (!kind || !Array.isArray(data.skills) || data.skills.length !== 1)
304
305
  continue;
305
306
  if (data.skills[0].skillName !== skillName)
306
307
  continue;
@@ -8,6 +8,7 @@ import { parseRunConfig } from '../../lib/parse-run-config.js';
8
8
  import { makeOnProgress } from '../../lib/progress.js';
9
9
  import { computeRunTally } from '../../lib/run-tally.js';
10
10
  import { DEFAULT_BOOTSTRAP_SAMPLES } from '../../../eval-core/bootstrap.js';
11
+ import { EVALUATION_REPORT_SCHEMA_VERSION } from '../../../eval-core/evaluation-reporting.js';
11
12
  function isDryRunReport(report) {
12
13
  return Boolean(report && typeof report === 'object' && report.dryRun === true);
13
14
  }
@@ -65,16 +66,16 @@ async function recordEvidenceSafely(report, verdict, values, lang) {
65
66
  }
66
67
  function batchItemFallbackReport(batch, item) {
67
68
  return {
68
- reportKind: 'evaluation',
69
+ kind: 'evaluation',
69
70
  id: item.reportId,
70
71
  meta: {
71
72
  ...batch.meta,
72
73
  variants: ['baseline', item.name],
73
74
  sampleCount: item.sampleCount,
74
75
  totalCostUSD: item.totalCostUSD,
75
- // item.artifactHash 来自子报告(走 aggregateReport 的整树哈),故 fallback 与之一致标 schemaVersion 3,
76
- // 避免「树哈 artifactHashes + 错位 schemaVersion」的错配。
77
- schemaVersion: 3,
76
+ // item.artifactHash 来自子报告(走 aggregateReport 的整树哈),故 fallback 与之一致标当前 eval
77
+ // report schemaVersion,避免「树哈 artifactHashes + 错位 schemaVersion」的错配。
78
+ schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
78
79
  artifactHashes: item.artifactHash ? { [item.name]: item.artifactHash } : {},
79
80
  },
80
81
  summary: item.summary,
@@ -88,7 +89,7 @@ async function loadBatchChildReports(batch, reportsDir, lang) {
88
89
  const reports = [];
89
90
  for (const item of batch.items) {
90
91
  const loaded = await store.get(item.reportId);
91
- if (loaded?.reportKind === 'evaluation') {
92
+ if (loaded?.kind === 'evaluation') {
92
93
  reports.push(loaded);
93
94
  }
94
95
  else {
@@ -128,7 +129,7 @@ async function emitBatchVerdict(report, reportsDir, values, lang) {
128
129
  }
129
130
  async function announceSavedReport({ report, filePath, reportsDir, values, lang, }) {
130
131
  const tally = computeRunTally(report);
131
- process.stderr.write(tCli(report.reportKind === 'batch-evaluation' ? 'cli.run.batch_complete' : 'cli.run.eval_complete', lang));
132
+ process.stderr.write(tCli(report.kind === 'batch-evaluation' ? 'cli.run.batch_complete' : 'cli.run.eval_complete', lang));
132
133
  process.stderr.write(tCli('cli.run.tally', lang, tally));
133
134
  process.stderr.write(tCli('cli.run.report_saved', lang, { path: filePath }));
134
135
  if (!values['no-serve'] && process.stdout.isTTY) {
@@ -0,0 +1,46 @@
1
+ import { BaseCommand } from '../oclif/base-command.js';
2
+ import { type ManagedListRow, type SourceProbe } from '../../managed/index.js';
3
+ import type { ManagedArtifactRecord } from '../../types/index.js';
4
+ import type { CliLang } from '../lib/i18n.js';
5
+ /**
6
+ * 探测一条受管记录**当前源**的状态(三态,喂 buildManagedListRow 判 drift / 生命周期)。
7
+ * - 远端 git(带 url):源身份钉不可变 SHA(install 时 pin),内容恒定 → 直接取 record.contentHash,
8
+ * 不联网(list 是快读命令,不为 drift 检查发网络请求)。reachable。
9
+ * - 本地 git:locator `git:<ref>:<spec>` 复用 resolveInstallSource 在**仓库对象库**内重物化重哈
10
+ * (读取受 git 边界约束,无任意文件读 DoS)。解析不到(常因 `omk list` 的 cwd 与 install 时不同、
11
+ * spec 随 cwd 漂)→ 抛错 → **reachable:false(未核,不当 stale)**,避免对未改动的 skill 误报漂移。
12
+ * - 本地 file:locator 是绝对路径,但受管 JSON **用户可手改 / 随仓库分发**(无 install、无 opt-in 即被
13
+ * loadAllManagedRecords 读到)。只读命令绝不盲读任意路径:**拒软链、只读常规文件 / 真目录、单文件
14
+ * 设 size cap**(挡 `evil.md → /dev/zero` 这类 readFileSync 无界 DoS 与项目外任意读)。守卫不过 →
15
+ * reachable:false。目录-skill 整树哈本就跳软链(hashArtifactSource 用 isFile() 过滤)。
16
+ */
17
+ export declare function probeSourceState(record: ManagedArtifactRecord): SourceProbe;
18
+ /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
19
+ export declare function dispWidth(s: string): number;
20
+ /** 按**显示宽度**截断(不是 code unit):逐码点累加 dispWidth,绝不切断 surrogate 对、CJK 也不溢出列。 */
21
+ export declare function truncate(s: string, max: number): string;
22
+ /** 洗不可信显示字符:managed JSON 可随仓库分发,name / sourceLabel / verdict 等字段塞进表格前必须先洗,
23
+ * 否则会破坏表格甚至伪造终端输出。一律映射到可见 U+FFFD(--json 路径保留原值给脚本)。
24
+ * 用 Unicode **属性类**而非手列码点 —— 手列清单天然有缺口(BiDi、U+2028 / 2029、Tags 块都曾漏一轮补一轮),
25
+ * 属性类一次覆盖整类、新码点自动纳入:
26
+ * - `\p{Cc}` 控制符(C0 / C1 / DEL,含 ESC / 换行 / 回车 / TAB)→ 杀 ANSI / OSC 转义与终端控制;
27
+ * - `\p{Cf}` 格式符(BiDi 重排 / 隔离、零宽、joiners、BOM、Tags、interlinear)→ 防 Trojan-Source 视觉伪造与零宽隐藏 / 分割;
28
+ * - `\p{Zl}` / `\p{Zp}` 行 / 段分隔(U+2028 / 2029)→ LF 的 Unicode 孪生,防换行伪造表格行;
29
+ * - `\p{Mn}` / `\p{Me}` 非间距 / 封闭组合附加符 → 变可见,避免零前进宽度令 dispWidth 与终端列错位
30
+ * (间距组合符 `\p{Mc}` 合法占 1 列,保留);
31
+ * - Hangul filler(U+115F / U+1160 / U+3164,属 Lo 不在上述任何类)→ 零宽显示诡计,补列。 */
32
+ export declare function sanitizeCell(s: string): string;
33
+ export declare function renderTable(rows: ManagedListRow[], lang: CliLang): string;
34
+ export default class List extends BaseCommand {
35
+ static description: string;
36
+ static examples: {
37
+ description: string;
38
+ command: string;
39
+ }[];
40
+ static flags: {
41
+ lang: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
42
+ global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
43
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
44
+ };
45
+ run(): Promise<void>;
46
+ }
@@ -0,0 +1,243 @@
1
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import { Flags } from '@oclif/core';
4
+ import { LANG_FLAG, bilingual } from '../oclif/i18n.js';
5
+ import { BaseCommand } from '../oclif/base-command.js';
6
+ import { tCli } from '../lib/i18n.js';
7
+ import { resolveInstallSource } from '../../inputs/source-resolver.js';
8
+ import { buildManagedListRows, globalManagedDir, hashArtifactSource, isDistributablePath, loadAllManagedRecords, managedDir, resolveManagedDir, } from '../../managed/index.js';
9
+ // 本地源读取成本上限。skill 是小体量 markdown(+少量 references 资产),远超这些边界的源必是手改 /
10
+ // 投毒,拒读避免 `omk list`(只读命令,读盘上可能随仓库分发的 locator)被 /dev/zero / 超大文件 / 巨型
11
+ // 目录递归拖垮(DoS)。
12
+ const MAX_FILE_SOURCE_BYTES = 8 * 1024 * 1024; // 单文件-skill 上限
13
+ const MAX_DIR_SOURCE_BYTES = 64 * 1024 * 1024; // 目录-skill 整树累计上限
14
+ const MAX_DIR_SOURCE_FILES = 4000; // 目录-skill 文件数上限
15
+ const MAX_DIR_SOURCE_DEPTH = 64; // 目录递归深度上限
16
+ /**
17
+ * 目录-skill 源的**有边界**整树哈:先恢复 resolveFileSource 的形态校验(SKILL.md 存在、是常规文件、非
18
+ * 软链 —— 否则 locator 可指向任意可读目录让 list 递归读整棵树),再 stat-walk(只 stat 不读)按与
19
+ * hashArtifactSource 同一 `isDistributablePath` 过滤累计 文件数 / 字节 / 深度,超界即返回 null(unreachable),
20
+ * 把单文件 DoS 不再换成目录递归 DoS。通过后才真正 hashArtifactSource。返回 null = 拒读 / 非法 skill 目录。
21
+ */
22
+ function boundedDirSkillHash(abs) {
23
+ const skillMd = join(abs, 'SKILL.md');
24
+ let md;
25
+ try {
26
+ md = lstatSync(skillMd);
27
+ }
28
+ catch {
29
+ return null; // 无 SKILL.md → 不是 skill 目录,拒
30
+ }
31
+ if (md.isSymbolicLink() || !md.isFile() || md.nlink !== 1)
32
+ return null; // SKILL.md 必须是常规、非软链、非硬链
33
+ let files = 0;
34
+ let bytes = 0;
35
+ const within = (dir, segs, depth) => {
36
+ if (depth > MAX_DIR_SOURCE_DEPTH)
37
+ return false;
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(dir, { withFileTypes: true });
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ for (const e of entries) {
46
+ const ns = [...segs, e.name];
47
+ if (!isDistributablePath(ns))
48
+ continue; // 与 hashArtifactSource 读取范围一致
49
+ if (e.isDirectory()) {
50
+ if (!within(join(dir, e.name), ns, depth + 1))
51
+ return false;
52
+ }
53
+ else if (e.isFile()) { // 软链 Dirent 既非 isFile 也非 isDirectory → 天然跳过
54
+ if (++files > MAX_DIR_SOURCE_FILES)
55
+ return false;
56
+ try {
57
+ const fst = lstatSync(join(dir, e.name));
58
+ if (fst.nlink !== 1)
59
+ return false; // 硬链(nlink>1)可在树内别名树外敏感 inode → 拒读整树
60
+ bytes += fst.size;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ if (bytes > MAX_DIR_SOURCE_BYTES)
66
+ return false;
67
+ }
68
+ }
69
+ return true;
70
+ };
71
+ if (!within(abs, [], 0))
72
+ return null;
73
+ return hashArtifactSource(abs, true);
74
+ }
75
+ /**
76
+ * 探测一条受管记录**当前源**的状态(三态,喂 buildManagedListRow 判 drift / 生命周期)。
77
+ * - 远端 git(带 url):源身份钉不可变 SHA(install 时 pin),内容恒定 → 直接取 record.contentHash,
78
+ * 不联网(list 是快读命令,不为 drift 检查发网络请求)。reachable。
79
+ * - 本地 git:locator `git:<ref>:<spec>` 复用 resolveInstallSource 在**仓库对象库**内重物化重哈
80
+ * (读取受 git 边界约束,无任意文件读 DoS)。解析不到(常因 `omk list` 的 cwd 与 install 时不同、
81
+ * spec 随 cwd 漂)→ 抛错 → **reachable:false(未核,不当 stale)**,避免对未改动的 skill 误报漂移。
82
+ * - 本地 file:locator 是绝对路径,但受管 JSON **用户可手改 / 随仓库分发**(无 install、无 opt-in 即被
83
+ * loadAllManagedRecords 读到)。只读命令绝不盲读任意路径:**拒软链、只读常规文件 / 真目录、单文件
84
+ * 设 size cap**(挡 `evil.md → /dev/zero` 这类 readFileSync 无界 DoS 与项目外任意读)。守卫不过 →
85
+ * reachable:false。目录-skill 整树哈本就跳软链(hashArtifactSource 用 isFile() 过滤)。
86
+ */
87
+ export function probeSourceState(record) {
88
+ const s = record.source;
89
+ if (s.sourceKind === 'git' && s.url)
90
+ return { reachable: true, hash: record.contentHash };
91
+ try {
92
+ if (s.sourceKind === 'git') {
93
+ const resolved = resolveInstallSource(s.locator);
94
+ try {
95
+ return { reachable: true, hash: hashArtifactSource(resolved.localRoot, resolved.isDirectorySkill) };
96
+ }
97
+ finally {
98
+ resolved.cleanup();
99
+ }
100
+ }
101
+ // 本地 file 源:守卫后再读。locator 必须是 install 实际写出的形态(绝对路径) —— 受管 JSON 随仓库
102
+ // 分发、无 opt-in 即被读到,相对 locator 不是 install 产物,拒,避免按 cwd 解析到项目外。
103
+ if (!isAbsolute(s.locator))
104
+ return { reachable: false };
105
+ const abs = resolve(s.locator);
106
+ if (!existsSync(abs))
107
+ return { reachable: false };
108
+ const st = lstatSync(abs); // lstat:不跟随软链 —— 软链直接拒(防 evil.md → /dev/zero)
109
+ if (st.isSymbolicLink())
110
+ return { reachable: false };
111
+ if (s.isDirectorySkill) {
112
+ if (!st.isDirectory())
113
+ return { reachable: false };
114
+ const hash = boundedDirSkillHash(abs); // 形态校验 + 成本边界(防任意目录递归读 / 目录 DoS)
115
+ return hash === null ? { reachable: false } : { reachable: true, hash };
116
+ }
117
+ // 单文件-skill:恢复 install(resolveFileSource)的形态约束 —— 必须是 `.md` 常规文件、非硬链、≤ size cap。
118
+ // 否则攻击者可写 locator:`/etc/passwd` / `~/.ssh/id_rsa`(非 .md 直接拒),或用 `.md` 命名的**硬链**别名
119
+ // 树外敏感文件绕过扩展名 / 软链守卫(lstat 分不出硬链)→ 诱 list 把任意本地文件读进进程参与 hash。install
120
+ // 写出的是 nlink=1 的全新副本,拒 nlink>1 不误伤合法源。非 install 形态一律 reachable:false。
121
+ if (!/\.md$/i.test(abs) || !st.isFile() || st.nlink !== 1 || st.size > MAX_FILE_SOURCE_BYTES)
122
+ return { reachable: false };
123
+ return { reachable: true, hash: hashArtifactSource(abs, false) };
124
+ }
125
+ catch {
126
+ return { reachable: false };
127
+ }
128
+ }
129
+ /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
130
+ export function dispWidth(s) {
131
+ let w = 0;
132
+ for (const ch of s)
133
+ w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1;
134
+ return w;
135
+ }
136
+ function pad(s, width) {
137
+ return s + ' '.repeat(Math.max(0, width - dispWidth(s)));
138
+ }
139
+ /** 按**显示宽度**截断(不是 code unit):逐码点累加 dispWidth,绝不切断 surrogate 对、CJK 也不溢出列。 */
140
+ export function truncate(s, max) {
141
+ if (dispWidth(s) <= max)
142
+ return s;
143
+ let w = 0;
144
+ let out = '';
145
+ for (const ch of s) {
146
+ const cw = dispWidth(ch);
147
+ if (w + cw > max - 1)
148
+ break; // 留 1 列给省略号
149
+ out += ch;
150
+ w += cw;
151
+ }
152
+ return `${out}…`;
153
+ }
154
+ /** 洗不可信显示字符:managed JSON 可随仓库分发,name / sourceLabel / verdict 等字段塞进表格前必须先洗,
155
+ * 否则会破坏表格甚至伪造终端输出。一律映射到可见 U+FFFD(--json 路径保留原值给脚本)。
156
+ * 用 Unicode **属性类**而非手列码点 —— 手列清单天然有缺口(BiDi、U+2028 / 2029、Tags 块都曾漏一轮补一轮),
157
+ * 属性类一次覆盖整类、新码点自动纳入:
158
+ * - `\p{Cc}` 控制符(C0 / C1 / DEL,含 ESC / 换行 / 回车 / TAB)→ 杀 ANSI / OSC 转义与终端控制;
159
+ * - `\p{Cf}` 格式符(BiDi 重排 / 隔离、零宽、joiners、BOM、Tags、interlinear)→ 防 Trojan-Source 视觉伪造与零宽隐藏 / 分割;
160
+ * - `\p{Zl}` / `\p{Zp}` 行 / 段分隔(U+2028 / 2029)→ LF 的 Unicode 孪生,防换行伪造表格行;
161
+ * - `\p{Mn}` / `\p{Me}` 非间距 / 封闭组合附加符 → 变可见,避免零前进宽度令 dispWidth 与终端列错位
162
+ * (间距组合符 `\p{Mc}` 合法占 1 列,保留);
163
+ * - Hangul filler(U+115F / U+1160 / U+3164,属 Lo 不在上述任何类)→ 零宽显示诡计,补列。 */
164
+ export function sanitizeCell(s) {
165
+ return s.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Mn}\p{Me}ᅟᅠㅤ]/gu, '�');
166
+ }
167
+ export function renderTable(rows, lang) {
168
+ const headers = [
169
+ tCli('cli.list.col_name', lang),
170
+ tCli('cli.list.col_kind', lang),
171
+ tCli('cli.list.col_state', lang),
172
+ tCli('cli.list.col_verdict', lang),
173
+ tCli('cli.list.col_evidence', lang),
174
+ tCli('cli.list.col_source', lang),
175
+ ];
176
+ const cells = rows.map((r) => [
177
+ truncate(sanitizeCell(r.name), 40), // name 与 source 同为用户可控、可超长 → 同样按显示宽度截断,防撑爆表宽
178
+ r.kind, // ArtifactKind 枚举(validator 已收窄),无需洗
179
+ // 不可达 → 标「?」(drift 未核),绝不冒充 stale;reachable 且漂移才 stale ⚠️。
180
+ !r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state,
181
+ r.latestVerdict ? sanitizeCell(r.latestVerdict) : '—',
182
+ `${r.currentEvidenceCount}/${r.totalEvidenceCount}`,
183
+ truncate(sanitizeCell(r.sourceLabel), 48),
184
+ ]);
185
+ const widths = headers.map((h, i) => Math.max(dispWidth(h), ...cells.map((c) => dispWidth(c[i]))));
186
+ const line = (c) => c.map((v, i) => pad(v, widths[i])).join(' ').trimEnd();
187
+ return [line(headers), ...cells.map(line)].join('\n');
188
+ }
189
+ export default class List extends BaseCommand {
190
+ static description = bilingual({
191
+ zh: '列出受管 skill 及其证据状态:生命周期(installed / measurable / stale)、最新 verdict、证据数、源。',
192
+ en: 'List managed skills with evidence status: lifecycle (installed / measurable / stale), latest verdict, evidence count, source.',
193
+ });
194
+ static examples = [
195
+ { description: bilingual({ zh: '列出当前项目的受管 skill', en: 'List managed skills in the current project' }), command: '<%= config.bin %> list' },
196
+ { description: bilingual({ zh: '列出全局受管 skill', en: 'List globally managed skills' }), command: '<%= config.bin %> list --global' },
197
+ { description: bilingual({ zh: '机器可读 JSON 输出', en: 'Machine-readable JSON output' }), command: '<%= config.bin %> list --json' },
198
+ ];
199
+ static flags = {
200
+ lang: LANG_FLAG,
201
+ global: Flags.boolean({
202
+ description: bilingual({ zh: '看全局受管目录(~/.oh-my-knowledge/managed)而非项目 .omk/managed', en: 'Show the global managed dir (~/.oh-my-knowledge/managed) instead of project .omk/managed' }),
203
+ }),
204
+ json: Flags.boolean({
205
+ description: bilingual({ zh: '输出 JSON(含完整可比性 marker),供脚本消费', en: 'Output JSON (with full comparability markers) for scripts' }),
206
+ }),
207
+ };
208
+ async run() {
209
+ const { flags } = await this.parse(List);
210
+ const lang = this.lang;
211
+ await this.runWithCliExit(async () => {
212
+ const dir = flags.global ? globalManagedDir() : resolveManagedDir(managedDir());
213
+ const records = loadAllManagedRecords(dir);
214
+ const rows = buildManagedListRows(records, probeSourceState);
215
+ if (flags.json) {
216
+ // 版本化信封 —— 与 eval / doctor / diagnosis 等机读出口一致(都带 schemaVersion),让未来字段增删 /
217
+ // 改名是可检测的版本 bump,而非脚本静默 break。rows 的逐行形态见 ManagedListRow。
218
+ this.log(JSON.stringify({ schemaVersion: 1, rows }, null, 2));
219
+ return;
220
+ }
221
+ if (rows.length === 0) {
222
+ process.stderr.write(tCli('cli.list.empty', lang));
223
+ process.stderr.write(tCli('cli.list.empty_hint', lang));
224
+ return;
225
+ }
226
+ const scope = dir === globalManagedDir()
227
+ ? (lang === 'zh' ? '全局' : 'global')
228
+ : (lang === 'zh' ? '项目' : 'project');
229
+ process.stderr.write(tCli('cli.list.header', lang, { scope, count: rows.length }));
230
+ this.log(renderTable(rows, lang));
231
+ // 注脚块前留一行空白(无论先触发哪条 note);空行挂在块上而非 drift 分支,避免只 unreachable 时丢分隔。
232
+ const hasDrift = rows.some((r) => r.drifted);
233
+ const hasUnreachable = rows.some((r) => !r.reachable);
234
+ if (hasDrift || hasUnreachable)
235
+ process.stderr.write('\n');
236
+ if (hasDrift)
237
+ process.stderr.write(tCli('cli.list.drift_note', lang));
238
+ if (hasUnreachable)
239
+ process.stderr.write(tCli('cli.list.unreachable_note', lang));
240
+ process.stderr.write(tCli('cli.list.legend', lang));
241
+ });
242
+ }
243
+ }
@@ -0,0 +1,3 @@
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.legend';
3
+ export declare const listDict: Record<ListMessageKey, CliMessage>;
@@ -0,0 +1,32 @@
1
+ export const listDict = {
2
+ 'cli.list.header': {
3
+ zh: '受管 skill({scope},{count} 条)\n',
4
+ en: 'Managed skills ({scope}, {count})\n',
5
+ },
6
+ 'cli.list.empty': {
7
+ zh: '没有受管记录。\n',
8
+ en: 'No managed records.\n',
9
+ },
10
+ 'cli.list.empty_hint': {
11
+ zh: '用 omk install <skill> 登记并分发一个 skill 后,它会出现在这里。\n',
12
+ en: 'Run omk install <skill> to register and distribute a skill; it will show up here.\n',
13
+ },
14
+ 'cli.list.col_name': { zh: '名称', en: 'NAME' },
15
+ 'cli.list.col_kind': { zh: '类型', en: 'KIND' },
16
+ 'cli.list.col_state': { zh: '状态', en: 'STATE' },
17
+ 'cli.list.col_verdict': { zh: 'VERDICT', en: 'VERDICT' },
18
+ 'cli.list.col_evidence': { zh: '证据', en: 'EVIDENCE' },
19
+ 'cli.list.col_source': { zh: '源', en: 'SOURCE' },
20
+ 'cli.list.drift_note': {
21
+ zh: '⚠️ = 源内容已漂移、脱离证据(stale);重跑 omk eval 重新取证。\n',
22
+ en: '⚠️ = source content drifted off its evidence (stale); re-run omk eval to re-measure.\n',
23
+ },
24
+ 'cli.list.unreachable_note': {
25
+ zh: '? = 源此处不可达 / 拒读,drift 未核(非 stale);本地 git 源请在原仓库根目录跑 omk list。\n',
26
+ en: '? = source unreachable / refused here, drift unchecked (not stale); for a local git source run omk list from the original repo root.\n',
27
+ },
28
+ 'cli.list.legend': {
29
+ zh: '证据列 = 当前有效 / 全部(历史含旧内容证据,供回滚)。\n',
30
+ en: 'EVIDENCE column = current / total (history keeps old-content evidence for rollback).\n',
31
+ },
32
+ };
@@ -56,8 +56,9 @@ import { type GenMessageKey } from './i18n-dict/gen.js';
56
56
  import { type HelpMessageKey } from './i18n-dict/help.js';
57
57
  import { type InitMessageKey } from './i18n-dict/init.js';
58
58
  import { type InstallMessageKey } from './i18n-dict/install.js';
59
+ import { type ListMessageKey } from './i18n-dict/list.js';
59
60
  import { type RunMessageKey } from './i18n-dict/run.js';
60
61
  import type { CliMessage } from './i18n-dict/types.js';
61
62
  export type { CliMessage } from './i18n-dict/types.js';
62
- export type CliMessageKey = CommonMessageKey | EvolveMessageKey | GenMessageKey | HelpMessageKey | InitMessageKey | InstallMessageKey | RunMessageKey;
63
+ export type CliMessageKey = CommonMessageKey | EvolveMessageKey | GenMessageKey | HelpMessageKey | InitMessageKey | InstallMessageKey | ListMessageKey | RunMessageKey;
63
64
  export declare const CLI_DICT: Record<CliMessageKey, CliMessage>;
@@ -56,6 +56,7 @@ import { genDict } from './i18n-dict/gen.js';
56
56
  import { helpDict } from './i18n-dict/help.js';
57
57
  import { initDict } from './i18n-dict/init.js';
58
58
  import { installDict } from './i18n-dict/install.js';
59
+ import { listDict } from './i18n-dict/list.js';
59
60
  import { runDict } from './i18n-dict/run.js';
60
61
  export const CLI_DICT = {
61
62
  ...commonDict,
@@ -64,5 +65,6 @@ export const CLI_DICT = {
64
65
  ...helpDict,
65
66
  ...initDict,
66
67
  ...installDict,
68
+ ...listDict,
67
69
  ...runDict,
68
70
  };
@@ -19,7 +19,7 @@ export function computeRunTally(report) {
19
19
  failed += s.errorCount;
20
20
  }
21
21
  };
22
- if (report.reportKind === 'batch-evaluation') {
22
+ if (report.kind === 'batch-evaluation') {
23
23
  for (const item of report.items)
24
24
  accumulate(item.summary);
25
25
  }
@@ -5,7 +5,7 @@ export function requireEvaluationReport(report, id, lang) {
5
5
  console.error(tCli('cli.common.report_not_found', lang, { id }));
6
6
  throw new CliExit(1);
7
7
  }
8
- if (report.reportKind === 'batch-evaluation') {
8
+ if (report.kind === 'batch-evaluation') {
9
9
  console.error(lang === 'zh'
10
10
  ? `报告 ${id} 是 BatchEvaluationReport。该命令需要单次 EvaluationReport;请使用其中的 child reportId。`
11
11
  : `Report ${id} is a BatchEvaluationReport. This command requires an EvaluationReport; use a child reportId from the batch.`);
@@ -229,7 +229,7 @@ export async function runDoctor(opts) {
229
229
  ? 'warnings_only'
230
230
  : 'passed';
231
231
  return {
232
- reportKind: 'doctor',
232
+ kind: 'doctor',
233
233
  schemaVersion: DOCTOR_REPORT_SCHEMA_VERSION,
234
234
  id: nextReportId(),
235
235
  timestamp: new Date().toISOString(),
@@ -1,5 +1,6 @@
1
1
  import type { Artifact, Report, Sample, Task, VariantResult, GitInfo, EvaluationJob, EvaluationRequest, EvaluationRun } from '../types/index.js';
2
2
  export declare const DEFAULT_OUTPUT_DIR: string;
3
+ export declare const EVALUATION_REPORT_SCHEMA_VERSION = 4;
3
4
  export declare function hashString(str: string): string;
4
5
  /**
5
6
  * Stable content hash of a sample. Hashes the prompt + assertions + dimensions/rubric
@@ -22,6 +22,7 @@ function findPackageJson(startDir) {
22
22
  }
23
23
  const PKG = JSON.parse(readFileSync(findPackageJson(__dirname), 'utf-8'));
24
24
  export const DEFAULT_OUTPUT_DIR = join(homedir(), '.oh-my-knowledge', 'reports');
25
+ export const EVALUATION_REPORT_SCHEMA_VERSION = 4;
25
26
  export function hashString(str) {
26
27
  return createHash('sha256').update(str).digest('hex').slice(0, 12);
27
28
  }
@@ -182,7 +183,7 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
182
183
  debiasModeList.push('length');
183
184
  const totalCostReported = Object.values(summary).every((variant) => variant.execCostReported !== false && variant.judgeCostReported !== false);
184
185
  return {
185
- reportKind: 'evaluation',
186
+ kind: 'evaluation',
186
187
  id: runId,
187
188
  meta: {
188
189
  variants,
@@ -197,9 +198,10 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
197
198
  cliVersion: getCliVersion(),
198
199
  nodeVersion: process.version,
199
200
  // schemaVersion 3 起,所有 dir-skill(本地 + git)都经隔离副本物化、整棵可分发树哈,与 install
200
- // 受管记录 contentHash 同空间(evidence 全绑)。2 是过渡纪元(本地 dir-skill 树哈、git dir-skill
201
- // SKILL.md 字节、不绑);git dir-skill 的 v2 与 v3 不可比。作判别位:消费方对缺位/旧报告不错配比对。
202
- schemaVersion: 3,
201
+ // 受管记录 contentHash 同空间(evidence 全绑)。4 延续 v3 的哈/绑定义,并作为当前 canonical
202
+ // 顶层判别字段纪元,方便外部消费方按版本识别 JSON 形状。2 是过渡纪元(本地 dir-skill 树哈、
203
+ // git dir-skill 仅 SKILL.md 字节、不绑);git dir-skill 的 v2 与 v3+ 不可比。
204
+ schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
203
205
  artifactHashes,
204
206
  sampleHashes,
205
207
  ...(noJudge ? {} : { judgePromptHash: getJudgePromptHash(lengthDebiasOn) }),
@@ -1,5 +1,5 @@
1
1
  import { dirname } from 'node:path';
2
- import { DEFAULT_OUTPUT_DIR, generateRunId, getCliVersion, getGitInfo, persistReport } from '../eval-core/evaluation-reporting.js';
2
+ import { DEFAULT_OUTPUT_DIR, EVALUATION_REPORT_SCHEMA_VERSION, generateRunId, getCliVersion, getGitInfo, persistReport, } from '../eval-core/evaluation-reporting.js';
3
3
  import { buildEvaluationRequest, createEvaluationRun, createSucceededJob, finalizeEvaluationRun } from '../eval-core/evaluation-job.js';
4
4
  import { getExecutorRuntimeFingerprint } from '../executors/runtime-fingerprint.js';
5
5
  import { createFileJobStore, DEFAULT_JOBS_DIR } from '../server/job-store.js';
@@ -135,11 +135,12 @@ export function buildBatchEvaluationReport({ batchRunId, skillDir, skillEntries,
135
135
  ...(noJudge ? {} : { runtime: getExecutorRuntimeFingerprint(jc.executor, jc.model, { skillDir }) }),
136
136
  }));
137
137
  const report = {
138
- reportKind: 'batch-evaluation',
138
+ kind: 'batch-evaluation',
139
139
  id: batchRunId,
140
140
  mode: 'skill',
141
141
  meta: {
142
142
  mode: 'skill',
143
+ schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
143
144
  model,
144
145
  executor: executorName,
145
146
  skillDir,
@@ -97,7 +97,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
97
97
  const { createFileStore } = await import('../server/report-store.js');
98
98
  const store = createFileStore(resolve(outputDir || DEFAULT_OUTPUT_DIR));
99
99
  const existing = await store.get(resume);
100
- if (existing?.reportKind === 'evaluation') {
100
+ if (existing?.kind === 'evaluation') {
101
101
  existingResults = {};
102
102
  for (const entry of existing.results || []) {
103
103
  existingResults[entry.sample_id] = entry.variants;
@@ -121,7 +121,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
121
121
  + ` 新 entries 不隔离 → 与现有 entries 不可比。建议恢复默认 strict-baseline。\n`);
122
122
  }
123
123
  }
124
- else if (existing?.reportKind === 'batch-evaluation') {
124
+ else if (existing?.kind === 'batch-evaluation') {
125
125
  process.stderr.write(`\n⚠️ report ${resume} is a BatchEvaluationReport; resume needs a child EvaluationReport, starting from scratch\n`);
126
126
  }
127
127
  else {