oh-my-knowledge 0.37.0 → 0.38.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 (92) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/assets/agent-skills/omk/SKILL.md +1 -1
  4. package/dist/assets/agent-skills/omk/references/commands.md +21 -12
  5. package/dist/authoring/evolver.d.ts +4 -1
  6. package/dist/authoring/evolver.js +13 -8
  7. package/dist/cli/commands/doctor.d.ts +1 -0
  8. package/dist/cli/commands/doctor.js +26 -6
  9. package/dist/cli/commands/eval/gold/compare.js +7 -4
  10. package/dist/cli/commands/eval/index.d.ts +1 -0
  11. package/dist/cli/commands/eval/index.js +14 -3
  12. package/dist/cli/commands/evolve.d.ts +1 -0
  13. package/dist/cli/commands/evolve.js +45 -0
  14. package/dist/cli/commands/init.js +18 -6
  15. package/dist/cli/commands/list.d.ts +0 -2
  16. package/dist/cli/commands/list.js +2 -7
  17. package/dist/cli/commands/observe/inbox.d.ts +1 -0
  18. package/dist/cli/commands/observe/inbox.js +14 -4
  19. package/dist/cli/commands/observe/index.d.ts +11 -0
  20. package/dist/cli/commands/observe/index.js +35 -12
  21. package/dist/cli/commands/observe/ingest.d.ts +1 -0
  22. package/dist/cli/commands/observe/ingest.js +13 -4
  23. package/dist/cli/commands/observe/show.d.ts +1 -0
  24. package/dist/cli/commands/observe/show.js +12 -2
  25. package/dist/cli/commands/promote.js +1 -2
  26. package/dist/cli/commands/sample.js +15 -9
  27. package/dist/cli/commands/studio.d.ts +2 -0
  28. package/dist/cli/commands/studio.js +62 -12
  29. package/dist/cli/lib/cmd-flags.d.ts +8 -0
  30. package/dist/cli/lib/i18n-dict/common.js +1 -1
  31. package/dist/cli/lib/i18n-dict/evolve.d.ts +1 -1
  32. package/dist/cli/lib/i18n-dict/evolve.js +8 -0
  33. package/dist/cli/lib/i18n-dict/help.js +27 -45
  34. package/dist/cli/lib/i18n-dict/init.js +2 -2
  35. package/dist/cli/lib/parse-run-config.d.ts +0 -1
  36. package/dist/cli/lib/parse-run-config.js +6 -4
  37. package/dist/cli/lib/record-evolve-outcome.d.ts +31 -0
  38. package/dist/cli/lib/record-evolve-outcome.js +110 -0
  39. package/dist/cli/lib/resolve-skill-input.d.ts +6 -0
  40. package/dist/cli/lib/resolve-skill-input.js +4 -2
  41. package/dist/diagnosis/types.js +1 -1
  42. package/dist/doctor/index.js +4 -1
  43. package/dist/eval-core/artifact-index.d.ts +106 -0
  44. package/dist/eval-core/artifact-index.js +277 -0
  45. package/dist/eval-core/default-dirs.d.ts +34 -0
  46. package/dist/eval-core/default-dirs.js +36 -0
  47. package/dist/eval-core/evaluation-execution.js +3 -3
  48. package/dist/eval-core/evaluation-reporting.d.ts +7 -0
  49. package/dist/eval-core/evaluation-reporting.js +20 -5
  50. package/dist/eval-core/execution-strategy.js +3 -3
  51. package/dist/eval-core/measurement-dirs.d.ts +18 -0
  52. package/dist/eval-core/measurement-dirs.js +76 -0
  53. package/dist/eval-workflows/batch-evaluation-workflow.js +2 -1
  54. package/dist/eval-workflows/evaluation-pipeline/run-state.js +2 -1
  55. package/dist/eval-workflows/run-evaluation.js +5 -2
  56. package/dist/executors/codex-cli.js +2 -2
  57. package/dist/executors/script.js +43 -4
  58. package/dist/inputs/materialize-copy.d.ts +1 -1
  59. package/dist/inputs/materialize-copy.js +3 -3
  60. package/dist/inputs/skill-loader.d.ts +1 -1
  61. package/dist/inputs/skill-loader.js +1 -1
  62. package/dist/managed/index.d.ts +2 -0
  63. package/dist/managed/index.js +2 -0
  64. package/dist/managed/list-query.d.ts +7 -0
  65. package/dist/managed/list-query.js +11 -0
  66. package/dist/managed/source-probe.d.ts +15 -0
  67. package/dist/managed/source-probe.js +129 -0
  68. package/dist/managed/store.d.ts +10 -0
  69. package/dist/managed/store.js +25 -2
  70. package/dist/observability/inbox.js +6 -3
  71. package/dist/observability/skill-health-analyzer.d.ts +3 -0
  72. package/dist/observability/skill-health-analyzer.js +2 -0
  73. package/dist/renderer/html-renderer.js +4 -2
  74. package/dist/renderer/managed-history-renderer.d.ts +5 -0
  75. package/dist/renderer/managed-history-renderer.js +255 -0
  76. package/dist/renderer/observation-inbox-renderer.js +19 -19
  77. package/dist/renderer/skill-detail-renderer.js +17 -8
  78. package/dist/renderer/skill-list-renderer.js +1 -0
  79. package/dist/server/indexed-report-store.d.ts +8 -0
  80. package/dist/server/indexed-report-store.js +130 -0
  81. package/dist/server/job-store.d.ts +0 -1
  82. package/dist/server/job-store.js +0 -2
  83. package/dist/server/report-server.d.ts +17 -3
  84. package/dist/server/report-server.js +231 -85
  85. package/dist/server/report-store.d.ts +14 -8
  86. package/dist/server/report-store.js +46 -0
  87. package/dist/server/skill-index.d.ts +7 -1
  88. package/dist/server/skill-index.js +48 -12
  89. package/dist/types/executor.d.ts +4 -2
  90. package/dist/types/report.d.ts +15 -0
  91. package/dist/types/skill-index.d.ts +3 -0
  92. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
+ import { materializeForCliConfigDir } from '../eval-core/mocks-runtime.js';
3
4
  import { DEFAULT_TIMEOUT_MS, interruptedExecResult, spawnWithSigintPropagation, timeoutExecResult, } from './shared.js';
4
5
  // script executor 由用户自定义,omk 无法保证它实现 skill 隔离。
5
6
  // 任何 allowedSkills(包括 [])下都 stderr 一次性 warn,不阻塞执行,
@@ -20,7 +21,7 @@ export function createScriptExecutor(command) {
20
21
  const args = parts.slice(1)
21
22
  .map((a) => a.replace(/^["']|["']$/g, ''))
22
23
  .map((a) => resolveExecutorPath(a, baseCwd));
23
- return async function scriptExecutor({ model, system, prompt, cwd, timeoutMs = DEFAULT_TIMEOUT_MS, allowedSkills }) {
24
+ return async function scriptExecutor({ model, system, prompt, cwd, timeoutMs = DEFAULT_TIMEOUT_MS, allowedSkills, mocks, mocksBaseDir, mocksStrict }) {
24
25
  if (allowedSkills !== undefined && !scriptIsolationWarned) {
25
26
  scriptIsolationWarned = true;
26
27
  process.stderr.write(`[omk] script executor 不参与 skill isolation:allowedSkills=${JSON.stringify(allowedSkills)} 在自定义 executor "${command}" 下无效。\n`
@@ -28,8 +29,38 @@ export function createScriptExecutor(command) {
28
29
  }
29
30
  const input = JSON.stringify({ model, system: system || '', prompt });
30
31
  const start = Date.now();
32
+ // mock 注入:复用 claude-cli 的物化逻辑(临时目录 + on-disk hook + mocks.json),把
33
+ // settings / mcp-config / mocks 文件路径通过 env 暴露给脚本。脚本若包的是 Claude Code
34
+ // 兼容 CLI,自行把 OMK_MOCK_SETTINGS_FILE 透传成 `--settings`(或把其中的 hook 打包成
35
+ // `--plugin-dir` 插件)即可复用 omk 的 PreToolUse mock hook;不支持的脚本忽略这些 env
36
+ // 即可(向后兼容)。env 是局部对象、不改 process.env,并发多 eval 各 spawn 独立快照、
37
+ // 互不串台;临时目录由 mkdtemp 唯一命名,finally 里 cleanup 删除。
38
+ const mockHandle = mocks && mocks.length > 0
39
+ ? materializeForCliConfigDir(mocks, mocksBaseDir, !!mocksStrict)
40
+ : null;
41
+ const env = { ...process.env };
42
+ if (mockHandle) {
43
+ Object.assign(env, mockHandle.env); // OMK_MOCKS_FILE
44
+ env.OMK_MOCK_SETTINGS_FILE = mockHandle.settingsFile;
45
+ if (mockHandle.mcpConfigFile)
46
+ env.OMK_MOCK_MCP_CONFIG_FILE = mockHandle.mcpConfigFile;
47
+ }
48
+ // 与 claude-cli / claude-sdk 对齐:每条返回路径(成功/解析失败/超时/中断/错误)都回填
49
+ // mockStats,出错样本也能看到 mock 命中统计。readStats 内部已对缺文件/坏 JSON 兜底,
50
+ // 这里再包一层 try/catch 防御(不让取统计反而把成功结果带崩)。必须在 finally cleanup
51
+ // 删临时目录之前读 —— return 表达式先求值,再走 finally,顺序正确。
52
+ const captureMockStats = () => {
53
+ if (!mockHandle)
54
+ return undefined;
55
+ try {
56
+ return mockHandle.readStats();
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ };
31
62
  const { child, done } = spawnWithSigintPropagation(cmd, args, {
32
- env: { ...process.env },
63
+ env,
33
64
  timeoutMs,
34
65
  ...(cwd && { cwd }),
35
66
  });
@@ -38,6 +69,7 @@ export function createScriptExecutor(command) {
38
69
  try {
39
70
  const r = await done;
40
71
  const durationMs = Date.now() - start;
72
+ const ms = captureMockStats();
41
73
  try {
42
74
  const data = JSON.parse(r.stdout);
43
75
  return {
@@ -46,6 +78,7 @@ export function createScriptExecutor(command) {
46
78
  inputTokens: data.inputTokens || 0, outputTokens: data.outputTokens || 0,
47
79
  cacheReadTokens: data.cacheReadTokens || 0, cacheCreationTokens: data.cacheCreationTokens || 0,
48
80
  costUSD: data.costUSD || 0, stopReason: data.stopReason || 'end', numTurns: data.numTurns || 1,
81
+ ...(ms && { mockStats: ms }),
49
82
  };
50
83
  }
51
84
  catch {
@@ -53,16 +86,18 @@ export function createScriptExecutor(command) {
53
86
  ok: true, output: r.stdout.trim(), durationMs, durationApiMs: 0,
54
87
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
55
88
  costUSD: 0, stopReason: 'end', numTurns: 1,
89
+ ...(ms && { mockStats: ms }),
56
90
  };
57
91
  }
58
92
  }
59
93
  catch (err) {
60
94
  const durationMs = Date.now() - start;
95
+ const ms = captureMockStats();
61
96
  const details = err;
62
97
  if (details.killedByTimeout)
63
- return timeoutExecResult(timeoutMs, durationMs);
98
+ return { ...timeoutExecResult(timeoutMs, durationMs), ...(ms && { mockStats: ms }) };
64
99
  if (details.killedBySignal)
65
- return interruptedExecResult(durationMs);
100
+ return { ...interruptedExecResult(durationMs), ...(ms && { mockStats: ms }) };
66
101
  const stderr = (details.stderr || '').trim();
67
102
  return {
68
103
  ok: false,
@@ -70,7 +105,11 @@ export function createScriptExecutor(command) {
70
105
  durationMs, durationApiMs: 0,
71
106
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
72
107
  costUSD: 0, output: null, stopReason: 'error', numTurns: 0,
108
+ ...(ms && { mockStats: ms }),
73
109
  };
74
110
  }
111
+ finally {
112
+ mockHandle?.cleanup();
113
+ }
75
114
  };
76
115
  }
@@ -11,7 +11,7 @@ export interface IsolatedCopy {
11
11
  contentHash: string;
12
12
  isDirectorySkill: boolean;
13
13
  }
14
- /** 隔离副本根目录(与 managed / isolated-cwd / reports 同 home-dir 模式)。
14
+ /** 隔离副本根目录 —— 可重生 scratch,收在 state/ 子树下(见 default-dirs)。
15
15
  * `OMK_TREES_DIR` 可重定位(测试隔离 / 用户迁移)。 */
16
16
  export declare function treesDir(): string;
17
17
  /**
@@ -1,11 +1,11 @@
1
1
  import { mkdtempSync, mkdirSync, cpSync, copyFileSync, existsSync, renameSync, rmSync, readdirSync, statSync, utimesSync, writeFileSync, unlinkSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { hashArtifactSource, distributableCopyFilter } from './content-hash.js';
5
- /** 隔离副本根目录(与 managed / isolated-cwd / reports 同 home-dir 模式)。
4
+ import { DEFAULT_TREES_DIR } from '../eval-core/default-dirs.js';
5
+ /** 隔离副本根目录 —— 可重生 scratch,收在 state/ 子树下(见 default-dirs)。
6
6
  * `OMK_TREES_DIR` 可重定位(测试隔离 / 用户迁移)。 */
7
7
  export function treesDir() {
8
- return process.env.OMK_TREES_DIR || join(homedir(), '.oh-my-knowledge', 'trees');
8
+ return process.env.OMK_TREES_DIR || DEFAULT_TREES_DIR;
9
9
  }
10
10
  /** 内容寻址副本默认上限(distinct 内容版本数);超出按 LRU(mtime)淘汰。
11
11
  * `OMK_TREES_MAX_ENTRIES` 调整:0 / 负数 = 不限。 */
@@ -134,7 +134,7 @@ export interface ResolveArtifactsOptions {
134
134
  * 显式 per-variant 隔离声明走 spec.allowedSkills(prepareEvaluationRun 按 spec 身份绑定),
135
135
  * 不经此处——resolveArtifacts 只认 strictBaseline 默认,隔离绑定收成单一来源。 */
136
136
  strictBaseline?: boolean;
137
- /** Default true. dir-skill 是否落地隔离副本(写 `~/.oh-my-knowledge/trees` 并设 execRoot)。
137
+ /** Default true. dir-skill 是否落地隔离副本(写 `~/.oh-my-knowledge/state/trees` 并设 execRoot)。
138
138
  * 只有 eval(执行隔离)需要;doctor / loadSkills 等纯读路径传 false,只算指纹与正文、不写副本。 */
139
139
  materialize?: boolean;
140
140
  }
@@ -511,7 +511,7 @@ export function variantExprToSkillName(expr) {
511
511
  * 本地源 localDir 是真源目录;git 源 localDir 是 materializeGitSkillTree 物化出的临时目录。
512
512
  * execRoot(副本)供 executor cwd / skillDir 锚定;skillRoot(真源)由各分支自行保留。
513
513
  * 只有 eval 路径需要副本(执行隔离);doctor / loadSkills 只需指纹与正文,materialize=false 不落副本、
514
- * 不写 `~/.oh-my-knowledge/trees`(避免纯读路径的副作用 I/O 与 trees 污染)。
514
+ * 不写 `~/.oh-my-knowledge/state/trees`(避免纯读路径的副作用 I/O 与 trees 污染)。
515
515
  */
516
516
  function isolateDirSkill(localDir, name, materialize) {
517
517
  const content = readFileSync(join(localDir, 'SKILL.md'), 'utf-8').trim();
@@ -5,4 +5,6 @@
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
7
  export * from './list-view.js';
8
+ export * from './list-query.js';
9
+ export * from './source-probe.js';
8
10
  export * from './promote-gate.js';
@@ -5,4 +5,6 @@
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
7
  export * from './list-view.js';
8
+ export * from './list-query.js';
9
+ export * from './source-probe.js';
8
10
  export * from './promote-gate.js';
@@ -0,0 +1,7 @@
1
+ import { type ManagedListRow } from './list-view.js';
2
+ /**
3
+ * 「读受管记录做展示」的单一编排:load 全部记录 → 逐条源探测(drift / 生命周期)→ 展示行。
4
+ * omk list(CLI) 与 Studio(report-server) 共用同一口径,避免两个交付层各串一份。
5
+ * dir 省略时走 loadAllManagedRecords 的 project→global 回退。
6
+ */
7
+ export declare function listManagedRows(dir?: string): ManagedListRow[];
@@ -0,0 +1,11 @@
1
+ import { buildManagedListRows } from './list-view.js';
2
+ import { loadAllManagedRecords } from './store.js';
3
+ import { probeSourceState } from './source-probe.js';
4
+ /**
5
+ * 「读受管记录做展示」的单一编排:load 全部记录 → 逐条源探测(drift / 生命周期)→ 展示行。
6
+ * omk list(CLI) 与 Studio(report-server) 共用同一口径,避免两个交付层各串一份。
7
+ * dir 省略时走 loadAllManagedRecords 的 project→global 回退。
8
+ */
9
+ export function listManagedRows(dir) {
10
+ return buildManagedListRows(loadAllManagedRecords(dir), probeSourceState);
11
+ }
@@ -0,0 +1,15 @@
1
+ import type { SourceProbe } from './list-view.js';
2
+ import type { ManagedArtifactRecord } from '../types/index.js';
3
+ /**
4
+ * 探测一条受管记录**当前源**的状态(三态,喂 buildManagedListRow 判 drift / 生命周期)。
5
+ * - 远端 git(带 url):源身份钉不可变 SHA(install 时 pin),内容恒定 → 直接取 record.contentHash,
6
+ * 不联网(快读命令不为 drift 检查发网络请求)。reachable。
7
+ * - 本地 git:locator `git:<ref>:<spec>` 复用 resolveInstallSource 在**仓库对象库**内重物化重哈
8
+ * (读取受 git 边界约束,无任意文件读 DoS)。解析不到(常因 cwd 与 install 时不同、spec 随 cwd 漂)
9
+ * → 抛错 → **reachable:false(未核,不当 stale)**,避免对未改动的 skill 误报漂移。
10
+ * - 本地 file:locator 是绝对路径,但受管 JSON **用户可手改 / 随仓库分发**(无 install、无 opt-in 即被
11
+ * loadAllManagedRecords 读到)。只读命令绝不盲读任意路径:**拒软链、只读常规文件 / 真目录、单文件
12
+ * 设 size cap**(挡 `evil.md → /dev/zero` 这类 readFileSync 无界 DoS 与项目外任意读)。守卫不过 →
13
+ * reachable:false。目录-skill 整树哈本就跳软链(hashArtifactSource 用 isFile() 过滤)。
14
+ */
15
+ export declare function probeSourceState(record: ManagedArtifactRecord): SourceProbe;
@@ -0,0 +1,129 @@
1
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
2
+ import { isAbsolute, resolve, join } from 'node:path';
3
+ import { resolveInstallSource } from '../inputs/source-resolver.js';
4
+ import { hashArtifactSource, isDistributablePath } from '../inputs/content-hash.js';
5
+ /**
6
+ * 受管记录**当前源**的状态探测——`omk list`(drift / 生命周期)与 `omk promote`(门禁前先确认源未漂)共用
7
+ * 的单一来源。两命令必须用同一套守卫与 drift 判定:否则 list 显示「未漂」而 promote 判「漂」(或反之)会
8
+ * 互相矛盾,且安全守卫一旦各写一份必然漂掉一份。
9
+ *
10
+ * 本地源读取成本上限:skill 是小体量 markdown(+少量 references 资产),远超这些边界的源必是手改 / 投毒,
11
+ * 拒读避免只读命令(读盘上可能随仓库分发的 locator)被 /dev/zero / 超大文件 / 巨型目录递归拖垮(DoS)。
12
+ */
13
+ const MAX_FILE_SOURCE_BYTES = 8 * 1024 * 1024; // 单文件-skill 上限
14
+ const MAX_DIR_SOURCE_BYTES = 64 * 1024 * 1024; // 目录-skill 整树累计上限
15
+ const MAX_DIR_SOURCE_FILES = 4000; // 目录-skill 文件数上限
16
+ const MAX_DIR_SOURCE_DEPTH = 64; // 目录递归深度上限
17
+ /**
18
+ * 目录-skill 源的**有边界**整树哈:先恢复 resolveFileSource 的形态校验(SKILL.md 存在、是常规文件、非
19
+ * 软链 —— 否则 locator 可指向任意可读目录让递归读整棵树),再 stat-walk(只 stat 不读)按与
20
+ * hashArtifactSource 同一 `isDistributablePath` 过滤累计 文件数 / 字节 / 深度,超界即返回 null(unreachable),
21
+ * 把单文件 DoS 不再换成目录递归 DoS。通过后才真正 hashArtifactSource。返回 null = 拒读 / 非法 skill 目录。
22
+ */
23
+ function boundedDirSkillHash(abs) {
24
+ const skillMd = join(abs, 'SKILL.md');
25
+ let md;
26
+ try {
27
+ md = lstatSync(skillMd);
28
+ }
29
+ catch {
30
+ return null; // 无 SKILL.md → 不是 skill 目录,拒
31
+ }
32
+ if (md.isSymbolicLink() || !md.isFile() || md.nlink !== 1)
33
+ return null; // SKILL.md 必须是常规、非软链、非硬链
34
+ let files = 0;
35
+ let bytes = 0;
36
+ const within = (dir, segs, depth) => {
37
+ if (depth > MAX_DIR_SOURCE_DEPTH)
38
+ return false;
39
+ let entries;
40
+ try {
41
+ entries = readdirSync(dir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ for (const e of entries) {
47
+ const ns = [...segs, e.name];
48
+ if (!isDistributablePath(ns))
49
+ continue; // 与 hashArtifactSource 读取范围一致
50
+ if (e.isDirectory()) {
51
+ if (!within(join(dir, e.name), ns, depth + 1))
52
+ return false;
53
+ }
54
+ else if (e.isFile()) { // 软链 Dirent 既非 isFile 也非 isDirectory → 天然跳过
55
+ if (++files > MAX_DIR_SOURCE_FILES)
56
+ return false;
57
+ try {
58
+ const fst = lstatSync(join(dir, e.name));
59
+ if (fst.nlink !== 1)
60
+ return false; // 硬链(nlink>1)可在树内别名树外敏感 inode → 拒读整树
61
+ bytes += fst.size;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ if (bytes > MAX_DIR_SOURCE_BYTES)
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ };
72
+ if (!within(abs, [], 0))
73
+ return null;
74
+ return hashArtifactSource(abs, true);
75
+ }
76
+ /**
77
+ * 探测一条受管记录**当前源**的状态(三态,喂 buildManagedListRow 判 drift / 生命周期)。
78
+ * - 远端 git(带 url):源身份钉不可变 SHA(install 时 pin),内容恒定 → 直接取 record.contentHash,
79
+ * 不联网(快读命令不为 drift 检查发网络请求)。reachable。
80
+ * - 本地 git:locator `git:<ref>:<spec>` 复用 resolveInstallSource 在**仓库对象库**内重物化重哈
81
+ * (读取受 git 边界约束,无任意文件读 DoS)。解析不到(常因 cwd 与 install 时不同、spec 随 cwd 漂)
82
+ * → 抛错 → **reachable:false(未核,不当 stale)**,避免对未改动的 skill 误报漂移。
83
+ * - 本地 file:locator 是绝对路径,但受管 JSON **用户可手改 / 随仓库分发**(无 install、无 opt-in 即被
84
+ * loadAllManagedRecords 读到)。只读命令绝不盲读任意路径:**拒软链、只读常规文件 / 真目录、单文件
85
+ * 设 size cap**(挡 `evil.md → /dev/zero` 这类 readFileSync 无界 DoS 与项目外任意读)。守卫不过 →
86
+ * reachable:false。目录-skill 整树哈本就跳软链(hashArtifactSource 用 isFile() 过滤)。
87
+ */
88
+ export function probeSourceState(record) {
89
+ const s = record.source;
90
+ if (s.sourceKind === 'git' && s.url)
91
+ return { reachable: true, hash: record.contentHash };
92
+ try {
93
+ if (s.sourceKind === 'git') {
94
+ const resolved = resolveInstallSource(s.locator);
95
+ try {
96
+ return { reachable: true, hash: hashArtifactSource(resolved.localRoot, resolved.isDirectorySkill) };
97
+ }
98
+ finally {
99
+ resolved.cleanup();
100
+ }
101
+ }
102
+ // 本地 file 源:守卫后再读。locator 必须是 install 实际写出的形态(绝对路径) —— 受管 JSON 随仓库
103
+ // 分发、无 opt-in 即被读到,相对 locator 不是 install 产物,拒,避免按 cwd 解析到项目外。
104
+ if (!isAbsolute(s.locator))
105
+ return { reachable: false };
106
+ const abs = resolve(s.locator);
107
+ if (!existsSync(abs))
108
+ return { reachable: false };
109
+ const st = lstatSync(abs); // lstat:不跟随软链 —— 软链直接拒(防 evil.md → /dev/zero)
110
+ if (st.isSymbolicLink())
111
+ return { reachable: false };
112
+ if (s.isDirectorySkill) {
113
+ if (!st.isDirectory())
114
+ return { reachable: false };
115
+ const hash = boundedDirSkillHash(abs); // 形态校验 + 成本边界(防任意目录递归读 / 目录 DoS)
116
+ return hash === null ? { reachable: false } : { reachable: true, hash };
117
+ }
118
+ // 单文件-skill:恢复 install(resolveFileSource)的形态约束 —— 必须是 `.md` 常规文件、非硬链、≤ size cap。
119
+ // 否则攻击者可写 locator:`/etc/passwd` / `~/.ssh/id_rsa`(非 .md 直接拒),或用 `.md` 命名的**硬链**别名
120
+ // 树外敏感文件绕过扩展名 / 软链守卫(lstat 分不出硬链)→ 诱 list 把任意本地文件读进进程参与 hash。install
121
+ // 写出的是 nlink=1 的全新副本,拒 nlink>1 不误伤合法源。非 install 形态一律 reachable:false。
122
+ if (!/\.md$/i.test(abs) || !st.isFile() || st.nlink !== 1 || st.size > MAX_FILE_SOURCE_BYTES)
123
+ return { reachable: false };
124
+ return { reachable: true, hash: hashArtifactSource(abs, false) };
125
+ }
126
+ catch {
127
+ return { reachable: false };
128
+ }
129
+ }
@@ -44,6 +44,16 @@ export declare function upsertManagedRecord(dir: string, record: ManagedArtifact
44
44
  * null —— 这是"管理是 install 显式 opt-in"的体现:eval 绝不为未纳管的 skill 凭空建记录。
45
45
  */
46
46
  export declare function appendManagedEvidence(dir: string, recordId: string, evidence: ManagedEvidenceRef): ManagedArtifactRecord | null;
47
+ /**
48
+ * 把受管记录的 drift 基线(contentHash)重锚到新源内容哈,保留 evidence / decisions / source /
49
+ * distribution 全不动。用于 `omk evolve` 把胜出版本写回 source 后,让记录跟上实际内容——否则记录的
50
+ * contentHash 仍指旧基线,list 会把被 evolve 改过的受管 skill 永久显示成 stale。
51
+ *
52
+ * 只动 contentHash:旧 hash 锚定的旧 evidence / promote 决定自动变「非当前」(其 contentHash ≠ 新基线)=
53
+ * 历史,不再冒充当前状态;新内容的证据 / 决定由调用方另行 append。已经是该 hash → no-op 不重写。记录不存在
54
+ * 返回 null(同 evidence / decision:管理是 install 显式 opt-in)。
55
+ */
56
+ export declare function rebaselineManagedContentHash(dir: string, recordId: string, newHash: string): ManagedArtifactRecord | null;
47
57
  /**
48
58
  * 当前内容(contentHash === record.contentHash)是否处于 promoted 态。决定是 append-only 事件流,
49
59
  * promote 与 rollback 互为反操作,故不能看「是否存在过 promote」,而要看**当前内容最近一条** promote/rollback
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join, normalize } from 'node:path';
4
3
  import { hashString } from '../eval-core/evaluation-reporting.js';
4
+ import { OMK_HOME } from '../eval-core/default-dirs.js';
5
5
  /**
6
6
  * 受管记录的 per-record 文件存储。一条记录一个 `.omk/managed/<id>.json`,镜像 report-store 的
7
7
  * 成熟模式(原子 tmp+rename):每次 install 只碰自己那个文件,independent write、不丢别人、
@@ -16,7 +16,7 @@ export function managedDir(cwd = process.cwd()) {
16
16
  return join(cwd, '.omk', 'managed');
17
17
  }
18
18
  export function globalManagedDir() {
19
- return join(homedir(), '.oh-my-knowledge', 'managed');
19
+ return join(OMK_HOME, 'managed');
20
20
  }
21
21
  export function recordPath(dir, id) {
22
22
  return join(dir, `${id}.json`);
@@ -239,6 +239,29 @@ export function appendManagedEvidence(dir, recordId, evidence) {
239
239
  }
240
240
  return merged;
241
241
  }
242
+ /**
243
+ * 把受管记录的 drift 基线(contentHash)重锚到新源内容哈,保留 evidence / decisions / source /
244
+ * distribution 全不动。用于 `omk evolve` 把胜出版本写回 source 后,让记录跟上实际内容——否则记录的
245
+ * contentHash 仍指旧基线,list 会把被 evolve 改过的受管 skill 永久显示成 stale。
246
+ *
247
+ * 只动 contentHash:旧 hash 锚定的旧 evidence / promote 决定自动变「非当前」(其 contentHash ≠ 新基线)=
248
+ * 历史,不再冒充当前状态;新内容的证据 / 决定由调用方另行 append。已经是该 hash → no-op 不重写。记录不存在
249
+ * 返回 null(同 evidence / decision:管理是 install 显式 opt-in)。
250
+ */
251
+ export function rebaselineManagedContentHash(dir, recordId, newHash) {
252
+ const prev = loadManagedRecord(dir, recordId);
253
+ if (!prev)
254
+ return null;
255
+ if (prev.contentHash === newHash)
256
+ return prev;
257
+ const merged = { ...prev, contentHash: newHash };
258
+ mkdirSync(dir, { recursive: true });
259
+ const path = recordPath(dir, recordId);
260
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
261
+ writeFileSync(tmp, JSON.stringify(merged, null, 2));
262
+ renameSync(tmp, path);
263
+ return merged;
264
+ }
242
265
  /**
243
266
  * 当前内容(contentHash === record.contentHash)是否处于 promoted 态。决定是 append-only 事件流,
244
267
  * promote 与 rollback 互为反操作,故不能看「是否存在过 promote」,而要看**当前内容最近一条** promote/rollback
@@ -1,14 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
4
+ import { OMK_HOME } from '../eval-core/default-dirs.js';
5
5
  import { extractGapSignalsFromTrace } from '../analysis/gap-analyzer.js';
6
6
  import { ccTracesToResultEntries } from './trace-adapter.js';
7
7
  import { isSearchToolCall, toolCallQuery } from '../shared/tool-search.js';
8
8
  import { durationMsBetween } from '../shared/time.js';
9
9
  import { buildObservationExperienceReport, normalizeObservationExperienceReport, } from './experience.js';
10
- export const DEFAULT_PROJECT_OBSERVATIONS_DIR = join(process.cwd(), '.omk', 'observations');
11
- export const DEFAULT_GLOBAL_OBSERVATIONS_DIR = join(homedir(), '.oh-my-knowledge', 'observations');
10
+ // observe inbox(观测收件箱)产物根目录。导出名沿用 *_OBSERVATIONS_DIR 以少动 importer,
11
+ // 但落盘目录已统一到 observe-inbox 词根(命令 omk observe inbox / 文件 *-observe-inbox.json / kind observe-inbox)。
12
+ // 项目级 .omk/observe-inbox 优先、全局兜底 —— 这套 project/global 归属是既有正常行为,本次只改名不改归属。
13
+ export const DEFAULT_PROJECT_OBSERVATIONS_DIR = join(process.cwd(), '.omk', 'observe-inbox');
14
+ export const DEFAULT_GLOBAL_OBSERVATIONS_DIR = join(OMK_HOME, 'observe-inbox');
12
15
  export const DEFAULT_OBSERVATIONS_DIR = DEFAULT_PROJECT_OBSERVATIONS_DIR;
13
16
  const OBSERVATION_INBOX_SCHEMA_VERSION = 2;
14
17
  function hashString(input) {
@@ -52,6 +52,9 @@ export interface SkillHealth {
52
52
  gap: GapReport;
53
53
  }
54
54
  export interface SkillHealthReport {
55
+ /** 顶层判别字段,与 observe-inbox / observe-experience 同属 observe-* 报告族。
56
+ * 可选:历史文件无此字段,reader 靠 meta+overall 识别,不强制(additive,非 BREAKING-SCHEMA)。 */
57
+ kind?: 'observe-health';
55
58
  meta: {
56
59
  tracePath: string;
57
60
  kbPath: string | null;
@@ -168,6 +168,7 @@ export function computeSkillHealthReport(tracePath, opts = {}) {
168
168
  }
169
169
  : { from: '', to: '' };
170
170
  return {
171
+ kind: 'observe-health',
171
172
  meta: {
172
173
  tracePath,
173
174
  kbPath: kbRoot,
@@ -235,6 +236,7 @@ function buildReport(segments, entries, sessions, tracePath, opts) {
235
236
  }
236
237
  : { from: '', to: '' };
237
238
  return {
239
+ kind: 'observe-health',
238
240
  meta: {
239
241
  tracePath,
240
242
  kbPath: kbRoot,
@@ -134,7 +134,7 @@ function renderRuntimeFingerprintTags(meta, lang) {
134
134
  }
135
135
  export function renderRunList(runs, lang = DEFAULT_LANG) {
136
136
  const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
137
- const skillHealthLink = `<a class="page-secondary-link" href="/analyses${langQ}">📊 <span data-i18n="skillHealthTitle">${t('skillHealthTitle', lang)}</span> →</a>`;
137
+ const skillHealthLink = `<a class="page-secondary-link" href="/observe-health${langQ}">📊 <span data-i18n="skillHealthTitle">${t('skillHealthTitle', lang)}</span> →</a>`;
138
138
  if (!runs || runs.length === 0) {
139
139
  return layout(t('title', lang), `
140
140
  <main>
@@ -149,7 +149,9 @@ export function renderRunList(runs, lang = DEFAULT_LANG) {
149
149
  // 顶部 verdict pill 主导视觉,id+date 是身份,scores 是成绩,底部 meta 是元数据,
150
150
  // delete 默认隐藏在 hover 出现避免误点。批量报告共用同一组件,batch pill 替代 verdict。
151
151
  const formatDateFromId = (id, fallbackTs) => {
152
- const idMatch = id.match(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})$/);
152
+ // 兼容两代 run id 后缀:旧 `…YYYYMMDD-HHmm`、新 `…YYYYMMDD-HHmmss-rand4`(含秒+随机)。
153
+ // 都从 id 直接派生本地展示时间(确定性,不走时区敏感的 toLocaleString);新增的秒段 + 随机后缀可选匹配。
154
+ const idMatch = id.match(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(?:\d{2}-[a-z0-9]+)?$/);
153
155
  if (idMatch)
154
156
  return `${idMatch[2]}/${idMatch[3]} ${idMatch[4]}:${idMatch[5]}`;
155
157
  return fallbackTs ? new Date(fallbackTs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
@@ -0,0 +1,5 @@
1
+ import type { Lang } from '../types/index.js';
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;
5
+ export declare function renderManagedList(rows: ManagedListRow[], lang: Lang): string;