oh-my-knowledge 0.34.0 → 0.35.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 (45) hide show
  1. package/README.md +3 -0
  2. package/README.zh.md +3 -0
  3. package/dist/assets/agent-skills/omk/references/commands.md +9 -0
  4. package/dist/authoring/evolver.js +12 -3
  5. package/dist/cli/commands/eval/index.d.ts +1 -0
  6. package/dist/cli/commands/eval/index.js +36 -2
  7. package/dist/cli/commands/install.d.ts +2 -0
  8. package/dist/cli/commands/install.js +45 -20
  9. package/dist/cli/commands/sample.d.ts +1 -1
  10. package/dist/cli/commands/sample.js +25 -12
  11. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  12. package/dist/cli/lib/i18n-dict/install.d.ts +1 -1
  13. package/dist/cli/lib/i18n-dict/install.js +20 -0
  14. package/dist/cli/lib/i18n-dict/run.d.ts +1 -1
  15. package/dist/cli/lib/i18n-dict/run.js +8 -0
  16. package/dist/cli/lib/parse-run-config/variant-resolution.js +7 -0
  17. package/dist/doctor/index.js +5 -5
  18. package/dist/eval-core/cache.d.ts +11 -3
  19. package/dist/eval-core/cache.js +13 -5
  20. package/dist/eval-core/dependency-checker.js +5 -3
  21. package/dist/eval-core/evaluation-execution.js +4 -1
  22. package/dist/eval-core/evaluation-reporting.js +17 -8
  23. package/dist/eval-core/execution-strategy.js +6 -4
  24. package/dist/eval-core/task-planner.js +2 -1
  25. package/dist/eval-workflows/evaluation-preparation.js +3 -1
  26. package/dist/inputs/content-hash.d.ts +28 -0
  27. package/dist/inputs/content-hash.js +106 -0
  28. package/dist/inputs/eval-config.js +44 -11
  29. package/dist/inputs/materialize-copy.d.ts +37 -0
  30. package/dist/inputs/materialize-copy.js +193 -0
  31. package/dist/inputs/skill-loader.d.ts +65 -2
  32. package/dist/inputs/skill-loader.js +308 -13
  33. package/dist/inputs/source-resolver.d.ts +16 -9
  34. package/dist/inputs/source-resolver.js +57 -77
  35. package/dist/managed/evidence.d.ts +22 -0
  36. package/dist/managed/evidence.js +143 -0
  37. package/dist/managed/index.d.ts +1 -0
  38. package/dist/managed/index.js +1 -0
  39. package/dist/managed/store.d.ts +16 -20
  40. package/dist/managed/store.js +40 -76
  41. package/dist/renderer/layout.js +2 -2
  42. package/dist/types/eval.d.ts +12 -1
  43. package/dist/types/managed.d.ts +29 -4
  44. package/dist/types/report.d.ts +17 -3
  45. package/package.json +3 -3
@@ -159,10 +159,12 @@ function buildPreflightEnv(artifacts) {
159
159
  return process.env;
160
160
  const extraPaths = [];
161
161
  for (const a of artifacts) {
162
- // git artifact 的 locator 是 git spec、非在盘目录,对它取 dirname 会得到伪路径;跳过(与 extractSkillDir 一致)。
163
- if (!a.locator || a.source === 'git')
162
+ // 与 extractSkillDir 一致:dir-skill 优先隔离副本 execRoot(副本无 node_modules、.bin 检查恒不命中);
163
+ // 否则本地文件-skill 取 .md 所在目录。git 文件-skill 的 locator 是 git spec、非在盘目录,无 execRoot
164
+ // 时跳过(取 dirname 会得伪路径)。
165
+ const dir = a.execRoot ?? (a.locator && a.source !== 'git' ? dirname(a.locator) : null);
166
+ if (!dir)
164
167
  continue;
165
- const dir = dirname(a.locator);
166
168
  const nodeBin = join(dir, 'node_modules', '.bin');
167
169
  if (existsSync(nodeBin))
168
170
  extraPaths.push(nodeBin);
@@ -85,7 +85,10 @@ export async function executeTasks({ tasks, executor, executorName, model, noJud
85
85
  // don't share cache entries, and include runtime fingerprint so a binary/SDK bump
86
86
  // cannot replay old-runtime outputs under new-runtime report metadata.
87
87
  // 同时把 mocks + mocksStrict 进 key:改 mock 配置必须重跑,不能命中老 cache。
88
- const key = cacheKey(model, executionPlan.cacheSystem, executionPlan.input.prompt, executionPlan.input.cwd, task.artifact.allowedSkills, effectiveExecutorName, executorRuntime.fingerprint, executionPlan.input.mocks, executionPlan.input.mocksStrict, effort);
88
+ const key = cacheKey(model, executionPlan.cacheSystem, executionPlan.input.prompt, executionPlan.input.cwd, task.artifact.allowedSkills, effectiveExecutorName, executorRuntime.fingerprint, executionPlan.input.mocks, executionPlan.input.mocksStrict, effort,
89
+ // artifact 内容指纹进 key:本地 dir-skill 改 references/ 资产只动 contentHash,system 不变,
90
+ // 不进 key 会命中旧输出贴到新 artifactHashes(静默污染)。
91
+ task.artifact.contentHash);
89
92
  const cached = cache?.get(key);
90
93
  const execStart = Date.now();
91
94
  if (cached) {
@@ -92,13 +92,14 @@ function buildExecutorRuntimesByVariant({ variants, model, executorName, tasks,
92
92
  if (runtimes[variant])
93
93
  continue;
94
94
  const artifact = artifacts.find((a) => a.name === variant);
95
- // git artifact 无在盘 skillDir(content 经 SDK 注入),与 baseline 一样取 null —— 与主路径
96
- // extractSkillDir 的判定一致,避免 git variant 的 runtime fingerprint 被 cwd 的 node_modules 污染。
97
- const fallbackSkillDir = artifact?.kind === 'baseline' || artifact?.source === 'git'
98
- ? null
99
- : artifact?.locator
100
- ? dirname(artifact.locator)
101
- : request?.skillDir;
95
+ // 与主路径 extractSkillDir 一致:dir-skill 优先隔离副本 execRoot(副本无 node_modules、PATH 不污染);
96
+ // 否则 baseline / git 文件-skill 取 null,本地文件-skill 取 .md 所在目录。
97
+ const fallbackSkillDir = artifact?.execRoot
98
+ ?? (artifact?.kind === 'baseline' || artifact?.source === 'git'
99
+ ? null
100
+ : artifact?.locator
101
+ ? dirname(artifact.locator)
102
+ : request?.skillDir);
102
103
  runtimes[variant] = getExecutorRuntimeFingerprint(executorName, model, {
103
104
  skillDir: fallbackSkillDir,
104
105
  });
@@ -151,7 +152,11 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
151
152
  }
152
153
  }
153
154
  }
154
- const artifactHashes = Object.fromEntries(artifacts.map((artifact) => [artifact.name, artifact.content ? hashString(artifact.content) : 'no-skill']));
155
+ // 整树内容指纹:解析期已由 resolveArtifacts 用 hashArtifactSource 算好挂在 artifact.contentHash 上
156
+ // (目录-skill 覆盖整棵可分发树含 references/ 资产、文件-skill 为单文件字节),与 install 受管记录的
157
+ // contentHash 落在同一空间——证据可绑定的前提,也修掉「只哈 SKILL.md 正文、改资产指纹不变」的资产瞎。
158
+ // baseline / 无 skill 记 'no-skill'。
159
+ const artifactHashes = Object.fromEntries(artifacts.map((artifact) => [artifact.name, artifact.contentHash ?? 'no-skill']));
155
160
  const sampleHashes = Object.fromEntries(samples.map((s) => [s.sample_id, hashSample(s)]));
156
161
  const judgeRepeat = request?.judgeRepeat && request.judgeRepeat > 1 ? request.judgeRepeat : undefined;
157
162
  const runtimeOptions = { skillDir: request?.skillDir };
@@ -191,6 +196,10 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
191
196
  timestamp: new Date().toISOString(),
192
197
  cliVersion: getCliVersion(),
193
198
  nodeVersion: process.version,
199
+ // 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,
194
203
  artifactHashes,
195
204
  sampleHashes,
196
205
  ...(noJudge ? {} : { judgePromptHash: getJudgePromptHash(lengthDebiasOn) }),
@@ -72,10 +72,12 @@ export function buildVariantConfig(artifact) {
72
72
  };
73
73
  }
74
74
  function extractSkillDir(artifact) {
75
- // git artifact 的 content 经 SDK 注入、根本不落盘,其 locator 是 git spec(`review` / `skills/review`)
76
- // 而非磁盘目录。若对它取 dirname,会得到相对进程 cwd 的伪 skillDir(`.` / `skills`),进而让 buildExecEnv
77
- // 把 `<cwd>/node_modules/.bin` 误并进 PATH、污染 runtime fingerprint —— 与 baseline(skillDir=null)
78
- // 不对称,造成 Δ 污染。git 与 baseline 一样无在盘 skillDir,返回 null。
75
+ // dir-skill(任意源)优先用隔离副本执行根 execRoot:agent cwd 锚副本、skillDir 也指副本。副本经
76
+ // distributable filter 物化、不含 node_modules → buildExecEnv 的 .bin 检查恒不命中 → PATH 不被污染。
77
+ if (artifact.execRoot)
78
+ return artifact.execRoot;
79
+ // git 文件-skill 无 execRoot 且 locator 是 git spec(`review` / `skills/review`)非磁盘目录,取 dirname
80
+ // 会得伪 skillDir 污染 PATH/runtime fingerprint → 与 baseline 一样返回 null。本地文件-skill 取 .md 所在目录。
79
81
  if (!artifact.locator || artifact.source === 'git')
80
82
  return null;
81
83
  return dirname(artifact.locator);
@@ -62,7 +62,8 @@ export function buildTasksFromArtifacts(samples, artifacts) {
62
62
  assertions: sample.assertions || null,
63
63
  dimensions: sample.dimensions || null,
64
64
  artifactContent: artifact.content,
65
- cwd: artifact.cwd || artifact.skillRoot || sample.cwd || null,
65
+ // 用户显式 cwd > execRoot(隔离副本,dir-skill 执行根) > skillRoot(真源,兜底) > sample.cwd
66
+ cwd: artifact.cwd || artifact.execRoot || artifact.skillRoot || sample.cwd || null,
66
67
  _sample: sample,
67
68
  });
68
69
  }
@@ -10,7 +10,9 @@ export async function prepareEvaluationRun({ samplesPath, skillDir, variantSpecs
10
10
  // 结构化传入 {expr, cwd}(与 variantSpecs 顺序一一对应),cwd 不再编码进 expr 字符串。
11
11
  // resolveArtifacts 只保留 strictBaseline 默认(baseline → []);per-variant 隔离声明走
12
12
  // spec.allowedSkills,在下面按 spec 身份绑定。
13
- const variantInputs = variantSpecs.map((spec) => ({ expr: spec.expr, cwd: spec.cwd }));
13
+ const variantInputs = variantSpecs.map((spec) => spec.git
14
+ ? { git: spec.git, cwd: spec.cwd, name: spec.name }
15
+ : { expr: spec.expr, cwd: spec.cwd });
14
16
  const resolvedArtifacts = resolveArtifacts(resolve(skillDir), variantInputs, { strictBaseline });
15
17
  // experimentRole / allowedSkills 按 spec 身份绑定。variantSpecs 与 resolvedArtifacts 顺序
16
18
  // 一一对应(resolveArtifacts 每个 input 产出一个 artifact),按 index 绑定,并把 spec.name
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 给定相对源根的路径分段(空数组 = 源根本身),判断是否进可分发树。hash 的 walk、copy 的 filter、
3
+ * git 物化共用此一处,保证三者完全一致。
4
+ *
5
+ * 关键:检查**每一段**而非只看叶子。本地 walk / cpSync 是逐层下降、命中目录即剪枝,只看叶子也够;
6
+ * 但 git ls-tree 给的是**扁平路径**(如 `.omk/samples.json`),只看叶子 `samples.json` 会让 `.omk`
7
+ * 内容漏过。故:任一段命中全局排除即排除;首段命中 root-only(evolve)即排除(嵌套同名是合法资产)。
8
+ */
9
+ export declare function isDistributablePath(segments: string[]): boolean;
10
+ /**
11
+ * cpSync 的 filter,口径与 hashArtifactSource 的 walk 完全一致(「分发出去的 == 算进 hash 的」):
12
+ * 源根永远拷;软链跳过(避免软链目标改了却不触发 drift、回避软链环);其余按 isDistributablePath
13
+ * 逐段判定。install 分发(copyArtifactToTarget)与 eval 隔离副本物化(materializeIsolatedCopy)
14
+ * 共用此一处 —— copy / hash / 副本三者绝不发散。
15
+ */
16
+ export declare function distributableCopyFilter(sourceRoot: string): (src: string) => boolean;
17
+ /**
18
+ * artifact 内容 hash —— drift baseline 与 evidence 绑定的依据。
19
+ * - 文件-skill:单个 .md 的字节;
20
+ * - 目录-skill:覆盖**整棵可分发目录树**(SKILL.md + references/ 等资产,但排除 .omk / .git /
21
+ * evolve 等评测迭代产物),按相对路径排序后把每个文件的`路径 + 字节长度 + 内容`喂进同一个
22
+ * sha256 —— 改任意资产都会令 hash 变化、drift 不漏;只补样本则 hash 不动。
23
+ * 用 createHash 直接喂 Buffer(字节级,二进制资产也稳;分隔符是运行时字节,源码里不引入任何不可见字符)。
24
+ * 读时(list / drift 检查 / eval 报告)用同一函数重算比对。
25
+ */
26
+ /** sha256 前 12 位的字节摘要 —— 单文件 / 单 blob 内容指纹的共用底座(整树哈、git 单文件哈都走它)。 */
27
+ export declare function hashBytes(buf: Buffer): string;
28
+ export declare function hashArtifactSource(source: string, isDirectorySkill: boolean): string;
@@ -0,0 +1,106 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync, lstatSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ /**
5
+ * artifact「可分发树」与内容指纹 —— inputs 层的纯内容哈希工具。install(受管记录)与 eval
6
+ * (report 的 artifactHashes)共用同一处,保证「同一个 skill,装出来与测出来的指纹落在同一空间」,
7
+ * 是证据随 artifact 走(evidence.contentHash === record.contentHash)的承重基础。
8
+ *
9
+ * 放在 inputs 层而非 managed:这两个函数语义上属内容哈希、无 managed 专属依赖;skill-loader、
10
+ * source-resolver、managed/store 都在它之上消费,inputs 是更低层,不成环。
11
+ */
12
+ /**
13
+ * 不进 artifact「可分发树」的条目 —— omk 的评测 / 迭代 / VCS / 系统产物,既不该被拷进 agent
14
+ * skill 目录,也不该计入 artifact contentHash。区分两类语义,避免误伤合法嵌套资产:
15
+ * - **任意层级排除**:隐藏元数据 / VCS / 系统 / 依赖目录,任何深度出现都是噪声
16
+ * (`.omk` = samples / managed / observations;`.git`;`node_modules`;OS 垃圾);
17
+ * - **仅源根第一层排除**:omk 保留的工作目录,只在 skill 根有保留语义,嵌套同名是用户合法资产
18
+ * (`evolve` 是 `<skillDir>/evolve/` 候选快照;但 `references/evolve/guide.md` 应正常分发并计入 hash)。
19
+ * spec 里 artifact content hash 与 sample-set hash 是分开的证据轴,故只补样本(.omk)不该改 hash。
20
+ */
21
+ const GLOBAL_EXCLUDED_NAMES = new Set(['.omk', '.git', 'node_modules', '.DS_Store', 'Thumbs.db']);
22
+ const ROOT_ONLY_EXCLUDED_NAMES = new Set(['evolve']);
23
+ /**
24
+ * 给定相对源根的路径分段(空数组 = 源根本身),判断是否进可分发树。hash 的 walk、copy 的 filter、
25
+ * git 物化共用此一处,保证三者完全一致。
26
+ *
27
+ * 关键:检查**每一段**而非只看叶子。本地 walk / cpSync 是逐层下降、命中目录即剪枝,只看叶子也够;
28
+ * 但 git ls-tree 给的是**扁平路径**(如 `.omk/samples.json`),只看叶子 `samples.json` 会让 `.omk`
29
+ * 内容漏过。故:任一段命中全局排除即排除;首段命中 root-only(evolve)即排除(嵌套同名是合法资产)。
30
+ */
31
+ export function isDistributablePath(segments) {
32
+ if (segments.length === 0)
33
+ return true; // 源根永远算
34
+ for (const seg of segments) {
35
+ if (GLOBAL_EXCLUDED_NAMES.has(seg))
36
+ return false;
37
+ }
38
+ if (ROOT_ONLY_EXCLUDED_NAMES.has(segments[0]))
39
+ return false;
40
+ return true;
41
+ }
42
+ /**
43
+ * cpSync 的 filter,口径与 hashArtifactSource 的 walk 完全一致(「分发出去的 == 算进 hash 的」):
44
+ * 源根永远拷;软链跳过(避免软链目标改了却不触发 drift、回避软链环);其余按 isDistributablePath
45
+ * 逐段判定。install 分发(copyArtifactToTarget)与 eval 隔离副本物化(materializeIsolatedCopy)
46
+ * 共用此一处 —— copy / hash / 副本三者绝不发散。
47
+ */
48
+ export function distributableCopyFilter(sourceRoot) {
49
+ return (src) => {
50
+ const rel = relative(sourceRoot, src);
51
+ if (rel === '')
52
+ return true;
53
+ if (lstatSync(src).isSymbolicLink())
54
+ return false;
55
+ return isDistributablePath(rel.split(sep));
56
+ };
57
+ }
58
+ /**
59
+ * artifact 内容 hash —— drift baseline 与 evidence 绑定的依据。
60
+ * - 文件-skill:单个 .md 的字节;
61
+ * - 目录-skill:覆盖**整棵可分发目录树**(SKILL.md + references/ 等资产,但排除 .omk / .git /
62
+ * evolve 等评测迭代产物),按相对路径排序后把每个文件的`路径 + 字节长度 + 内容`喂进同一个
63
+ * sha256 —— 改任意资产都会令 hash 变化、drift 不漏;只补样本则 hash 不动。
64
+ * 用 createHash 直接喂 Buffer(字节级,二进制资产也稳;分隔符是运行时字节,源码里不引入任何不可见字符)。
65
+ * 读时(list / drift 检查 / eval 报告)用同一函数重算比对。
66
+ */
67
+ /** sha256 前 12 位的字节摘要 —— 单文件 / 单 blob 内容指纹的共用底座(整树哈、git 单文件哈都走它)。 */
68
+ export function hashBytes(buf) {
69
+ return createHash('sha256').update(buf).digest('hex').slice(0, 12);
70
+ }
71
+ export function hashArtifactSource(source, isDirectorySkill) {
72
+ if (!isDirectorySkill) {
73
+ return hashBytes(readFileSync(source));
74
+ }
75
+ const rels = [];
76
+ const walk = (dir, segments) => {
77
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
78
+ const segs = [...segments, entry.name];
79
+ if (!isDistributablePath(segs))
80
+ continue;
81
+ // 软链既非 isFile 也非 isDirectory,天然跳过 —— 与 copyArtifactToTarget 的 filter 一致
82
+ // (hash 覆盖的 == 分发出去的),避免软链目标改变却不触发 drift,也回避软链环。
83
+ if (entry.isDirectory())
84
+ walk(join(dir, entry.name), segs);
85
+ else if (entry.isFile())
86
+ rels.push(segs.join('/'));
87
+ }
88
+ };
89
+ walk(source, []);
90
+ rels.sort();
91
+ const h = createHash('sha256');
92
+ const sep = Buffer.from([0]);
93
+ for (const rel of rels) {
94
+ const content = readFileSync(join(source, rel));
95
+ // 路径与内容都做长度前缀,彻底排除"不同树拼出同一串"的歧义(文件名虽不含 NUL,核心层仍按可注入防)。
96
+ h.update(String(Buffer.byteLength(rel)));
97
+ h.update(sep);
98
+ h.update(rel);
99
+ h.update(sep);
100
+ h.update(String(content.length));
101
+ h.update(sep);
102
+ h.update(content);
103
+ h.update(sep);
104
+ }
105
+ return h.digest('hex').slice(0, 12);
106
+ }
@@ -23,13 +23,19 @@ export function loadEvalConfig(configPath) {
23
23
  * so the downstream variant resolver can treat CLI and config uniformly.
24
24
  */
25
25
  export function configVariantsToSpecs(variants) {
26
- return variants.map((v) => ({
27
- name: v.name,
28
- role: v.role,
29
- expr: v.artifact, // 纯 artifact 身份;cwd 结构化携带,不再编码进 expr
30
- ...(v.cwd !== undefined && { cwd: v.cwd }),
31
- ...(v.allowedSkills !== undefined && { allowedSkills: v.allowedSkills }),
32
- }));
26
+ return variants.map((v) => {
27
+ // 远端 git:expr 落规范身份串 `git+<url>@<ref>:<spec>`(仅供 variantIdentity 去重、绝不 re-split),
28
+ // 真正解析走结构化 spec.git;本地 artifact 直接用其字符串身份。
29
+ const expr = v.git ? `git+${v.git.url}@${v.git.ref ?? 'HEAD'}:${v.git.spec}` : v.artifact;
30
+ return {
31
+ name: v.name,
32
+ role: v.role,
33
+ expr,
34
+ ...(v.git !== undefined && { git: v.git }),
35
+ ...(v.cwd !== undefined && { cwd: v.cwd }),
36
+ ...(v.allowedSkills !== undefined && { allowedSkills: v.allowedSkills }),
37
+ };
38
+ });
33
39
  }
34
40
  function validateEvalConfig(parsed, configPath) {
35
41
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
@@ -56,8 +62,31 @@ function validateEvalConfig(parsed, configPath) {
56
62
  if (typeof v.role !== 'string' || !VALID_ROLES.includes(v.role)) {
57
63
  throw new Error(`${configPath}: variants[${i}].role must be 'control' or 'treatment' (got: ${JSON.stringify(v.role)})`);
58
64
  }
59
- if (typeof v.artifact !== 'string' || !v.artifact) {
60
- throw new Error(`${configPath}: variants[${i}].artifact is required and must be a string`);
65
+ // artifact(本地字符串身份)与 git(远端结构化)二选一、不可兼有。
66
+ const hasArtifact = v.artifact !== undefined;
67
+ const hasGit = v.git !== undefined;
68
+ if (hasArtifact === hasGit) {
69
+ throw new Error(`${configPath}: variants[${i}] must have exactly one of 'artifact' or 'git'`);
70
+ }
71
+ if (hasArtifact && (typeof v.artifact !== 'string' || !v.artifact)) {
72
+ throw new Error(`${configPath}: variants[${i}].artifact must be a non-empty string`);
73
+ }
74
+ let git;
75
+ if (hasGit) {
76
+ if (typeof v.git !== 'object' || v.git === null || Array.isArray(v.git)) {
77
+ throw new Error(`${configPath}: variants[${i}].git must be an object { url, ref?, spec }`);
78
+ }
79
+ const g = v.git;
80
+ if (typeof g.url !== 'string' || !g.url) {
81
+ throw new Error(`${configPath}: variants[${i}].git.url is required and must be a string`);
82
+ }
83
+ if (typeof g.spec !== 'string' || !g.spec) {
84
+ throw new Error(`${configPath}: variants[${i}].git.spec is required and must be a string (in-repo skill path)`);
85
+ }
86
+ if (g.ref !== undefined && (typeof g.ref !== 'string' || !g.ref)) {
87
+ throw new Error(`${configPath}: variants[${i}].git.ref must be a non-empty string when present`);
88
+ }
89
+ git = { url: g.url, spec: g.spec, ...(g.ref !== undefined && { ref: g.ref }) };
61
90
  }
62
91
  if (v.cwd !== undefined && typeof v.cwd !== 'string') {
63
92
  throw new Error(`${configPath}: variants[${i}].cwd must be a string`);
@@ -88,7 +117,8 @@ function validateEvalConfig(parsed, configPath) {
88
117
  variants.push({
89
118
  name: v.name,
90
119
  role: v.role,
91
- artifact: v.artifact,
120
+ ...(hasArtifact && { artifact: v.artifact }),
121
+ ...(git !== undefined && { git }),
92
122
  cwd: v.cwd,
93
123
  ...(allowedSkills !== undefined && { allowedSkills }),
94
124
  });
@@ -245,7 +275,10 @@ function resolveConfigPaths(config, configDir) {
245
275
  goldDir: config.goldDir ? resolveRel(config.goldDir) : undefined,
246
276
  variants: config.variants.map((v) => ({
247
277
  ...v,
248
- artifact: isNonPathExpr(v.artifact) ? v.artifact : (looksLikePath(v.artifact) ? resolveRel(v.artifact) : v.artifact),
278
+ // git 变体无 artifact(git.url 是 URL、git.spec 是仓库相对路径,都不按本地路径解析,原样经 ...v 携带)。
279
+ ...(v.artifact !== undefined && {
280
+ artifact: isNonPathExpr(v.artifact) ? v.artifact : (looksLikePath(v.artifact) ? resolveRel(v.artifact) : v.artifact),
281
+ }),
249
282
  cwd: v.cwd ? resolveRel(v.cwd) : undefined,
250
283
  })),
251
284
  };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 内容寻址隔离副本 —— eval 把任意源(本地目录-skill / 本地单文件-skill / 本地 git ref / 远端 git)
3
+ * 在测量前落地成的那一份。executor 的 cwd 锚到 copyRoot,被测 agent 在隔离副本里跑、不碰用户真实
4
+ * 目录,references/ 资产成为真实运行时输入。路径按 contentHash 命名 → 同内容同路径 → task.cwd
5
+ * 稳定 → cache key / runtime fingerprint 稳定。
6
+ */
7
+ export interface IsolatedCopy {
8
+ /** 副本根:目录-skill 为目录 `<treesDir>/<hash>`、单文件-skill 为 `<treesDir>/<hash>/<name>.md`。 */
9
+ copyRoot: string;
10
+ /** = hashArtifactSource(copyRoot, isDirectorySkill);与 install 受管记录的 contentHash 同空间。 */
11
+ contentHash: string;
12
+ isDirectorySkill: boolean;
13
+ }
14
+ /** 隔离副本根目录(与 managed / isolated-cwd / reports 同 home-dir 模式)。
15
+ * `OMK_TREES_DIR` 可重定位(测试隔离 / 用户迁移)。 */
16
+ export declare function treesDir(): string;
17
+ /**
18
+ * 内容寻址副本的 LRU 回收:`<treesDir>/<hash>` 数超过上限时,按 mtime 从旧到新淘汰,直到回到上限内。
19
+ * **绝不删**被活进程占用的副本(`.locks/<hash>.<pid>` 且 pid 在世)—— 这才是「正在跑的 eval 的 cwd 不被删」
20
+ * 的真实保证(不靠 mtime 当 liveness)。grace 窗口是第二道兜底。软上限:被占用/grace 内的大批新副本会暂时
21
+ * 超限,待其释放/老化后回收。`.tmp-` 暂存与 `.locks` 不计、不删。
22
+ * materializeIsolatedCopy 命中走 utimes 触碰(LRU touch)+ 落锁;未命中落盘后落锁并调本函数;也可独立调用。
23
+ */
24
+ export declare function pruneTreesDir(opts?: {
25
+ maxEntries?: number;
26
+ graceMs?: number;
27
+ }): void;
28
+ /**
29
+ * 把一棵已在盘的本地源树(目录或单 .md)落地成内容寻址隔离副本。
30
+ * - 先从**源**算整树 / 单文件指纹(`hashArtifactSource` 与副本同 filter → 同 hash),不必先 copy
31
+ * - `<treesDir>/<hash>` 已存在 → 内容寻址命中,直接复用、**零 copy**(同 hash 内容必然相同)
32
+ * - 未命中 → 物化到同卷临时目录 `<treesDir>/.tmp-xxx`(目录-skill 用 install 同一 distributable
33
+ * filter;单文件-skill copyFile 到 `<tmp>/<name>.md`,使 `<hash>` 恒为目录、消歧),再原子
34
+ * `rename` 到 `<hash>`;rename 撞 EEXIST/ENOTEMPTY(并发已落同 hash)即删临时复用。
35
+ * 物化只发生一次/variant(resolveArtifacts 解析期),不随 sample 数放大。
36
+ */
37
+ export declare function materializeIsolatedCopy(localRoot: string, isDirectorySkill: boolean, name: string): IsolatedCopy;
@@ -0,0 +1,193 @@
1
+ import { mkdtempSync, mkdirSync, cpSync, copyFileSync, existsSync, renameSync, rmSync, readdirSync, statSync, utimesSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { hashArtifactSource, distributableCopyFilter } from './content-hash.js';
5
+ /** 隔离副本根目录(与 managed / isolated-cwd / reports 同 home-dir 模式)。
6
+ * `OMK_TREES_DIR` 可重定位(测试隔离 / 用户迁移)。 */
7
+ export function treesDir() {
8
+ return process.env.OMK_TREES_DIR || join(homedir(), '.oh-my-knowledge', 'trees');
9
+ }
10
+ /** 内容寻址副本默认上限(distinct 内容版本数);超出按 LRU(mtime)淘汰。
11
+ * `OMK_TREES_MAX_ENTRIES` 调整:0 / 负数 = 不限。 */
12
+ const DEFAULT_TREES_MAX_ENTRIES = 200;
13
+ /** 淘汰宽限:mtime 在此窗口内的副本不动。这是**第二道**保护(覆盖物化→落锁那几毫秒的窗口);
14
+ * 真正挡住「删掉正在跑的 eval 的 cwd」靠下面的 pid 占用锁,不靠「没有 eval 跑这么久」的假设。 */
15
+ const TREES_GRACE_MS = 60 * 60 * 1000;
16
+ function treesCap() {
17
+ const raw = process.env.OMK_TREES_MAX_ENTRIES;
18
+ if (raw == null || raw === '')
19
+ return DEFAULT_TREES_MAX_ENTRIES;
20
+ const n = Number(raw);
21
+ if (!Number.isFinite(n))
22
+ return DEFAULT_TREES_MAX_ENTRIES;
23
+ if (n <= 0)
24
+ return Infinity;
25
+ return Math.floor(n);
26
+ }
27
+ /** 占用锁目录:`<treesDir>/.locks/<hash>.<pid>`。dotfile → 不计入 cap、不被当副本删。 */
28
+ function locksDir() {
29
+ return join(treesDir(), '.locks');
30
+ }
31
+ function isPidAlive(pid) {
32
+ if (!Number.isInteger(pid) || pid <= 0)
33
+ return false;
34
+ try {
35
+ process.kill(pid, 0); // 信号 0:只探活、不真发信号
36
+ return true;
37
+ }
38
+ catch (err) {
39
+ // EPERM = 进程存在但无权限(仍算活);ESRCH = 不存在
40
+ return err.code === 'EPERM';
41
+ }
42
+ }
43
+ /**
44
+ * 标记本进程正在用某内容寻址副本作 executor cwd —— 落一个 `<treesDir>/.locks/<hash>.<pid>` 占用锁。
45
+ * pruneTreesDir 见到**活 pid** 的锁就绝不删对应副本(真实跨进程 liveness,不靠 mtime 猜)。进程崩溃也安全:
46
+ * 锁不显式回收,prune 惰性按 pid 探活清理死锁。同 hash 同 pid 幂等(覆盖写)。
47
+ */
48
+ function markCopyInUse(contentHash) {
49
+ try {
50
+ const dir = locksDir();
51
+ mkdirSync(dir, { recursive: true });
52
+ writeFileSync(join(dir, `${contentHash}.${process.pid}`), '');
53
+ }
54
+ catch {
55
+ // 落锁失败不致命:还有 grace 窗口兜底
56
+ }
57
+ reapDeadLocks(); // 每次落锁顺带清死 pid 的锁 —— 不依赖 prune(prune 仅 cache-miss + 超上限才跑,
58
+ // under-cap 的常见情形下永不触发,死锁会无界堆积)。readdir 小目录,开销可忽略。
59
+ }
60
+ /** 扫 `.locks/`:删掉死 pid 的锁、返回被**活进程**占用的 hash 集合。每次物化(markCopyInUse)无条件调用,
61
+ * 保证死锁惰性回收的路径始终可达;pruneTreesDir 也用其返回的 live 集合保护 active cwd。 */
62
+ function reapDeadLocks() {
63
+ const dir = locksDir();
64
+ const live = new Set();
65
+ let names;
66
+ try {
67
+ names = readdirSync(dir);
68
+ }
69
+ catch {
70
+ return live; // 无锁目录
71
+ }
72
+ for (const name of names) {
73
+ const dot = name.lastIndexOf('.');
74
+ if (dot <= 0)
75
+ continue;
76
+ const hash = name.slice(0, dot);
77
+ const pid = Number(name.slice(dot + 1));
78
+ if (isPidAlive(pid)) {
79
+ live.add(hash);
80
+ }
81
+ else {
82
+ try {
83
+ unlinkSync(join(dir, name)); // 死 pid 的锁惰性回收
84
+ }
85
+ catch {
86
+ // 清理失败不致命
87
+ }
88
+ }
89
+ }
90
+ return live;
91
+ }
92
+ /**
93
+ * 内容寻址副本的 LRU 回收:`<treesDir>/<hash>` 数超过上限时,按 mtime 从旧到新淘汰,直到回到上限内。
94
+ * **绝不删**被活进程占用的副本(`.locks/<hash>.<pid>` 且 pid 在世)—— 这才是「正在跑的 eval 的 cwd 不被删」
95
+ * 的真实保证(不靠 mtime 当 liveness)。grace 窗口是第二道兜底。软上限:被占用/grace 内的大批新副本会暂时
96
+ * 超限,待其释放/老化后回收。`.tmp-` 暂存与 `.locks` 不计、不删。
97
+ * materializeIsolatedCopy 命中走 utimes 触碰(LRU touch)+ 落锁;未命中落盘后落锁并调本函数;也可独立调用。
98
+ */
99
+ export function pruneTreesDir(opts = {}) {
100
+ const cap = opts.maxEntries ?? treesCap();
101
+ if (!Number.isFinite(cap))
102
+ return;
103
+ const graceMs = opts.graceMs ?? TREES_GRACE_MS;
104
+ const root = treesDir();
105
+ let names;
106
+ try {
107
+ names = readdirSync(root);
108
+ }
109
+ catch {
110
+ return; // 目录不存在 = 无可回收
111
+ }
112
+ const dirs = names
113
+ .filter((n) => !n.startsWith('.')) // 排除 .tmp- 暂存与 .locks
114
+ .map((n) => {
115
+ try {
116
+ return { path: join(root, n), mtimeMs: statSync(join(root, n)).mtimeMs };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ })
122
+ .filter((x) => x !== null);
123
+ if (dirs.length <= cap)
124
+ return;
125
+ const locked = reapDeadLocks();
126
+ dirs.sort((a, b) => a.mtimeMs - b.mtimeMs); // 最旧在前
127
+ const cutoff = Date.now() - graceMs;
128
+ let removable = dirs.length - cap;
129
+ for (const d of dirs) {
130
+ if (removable <= 0)
131
+ break;
132
+ if (locked.has(basename(d.path)))
133
+ continue; // 活进程占用(可能是 active cwd),绝不删
134
+ if (d.mtimeMs > cutoff)
135
+ continue; // grace 内:第二道兜底
136
+ try {
137
+ rmSync(d.path, { recursive: true, force: true });
138
+ removable--;
139
+ }
140
+ catch {
141
+ // 删除失败(权限 / 并发)不致命,跳过
142
+ }
143
+ }
144
+ }
145
+ /**
146
+ * 把一棵已在盘的本地源树(目录或单 .md)落地成内容寻址隔离副本。
147
+ * - 先从**源**算整树 / 单文件指纹(`hashArtifactSource` 与副本同 filter → 同 hash),不必先 copy
148
+ * - `<treesDir>/<hash>` 已存在 → 内容寻址命中,直接复用、**零 copy**(同 hash 内容必然相同)
149
+ * - 未命中 → 物化到同卷临时目录 `<treesDir>/.tmp-xxx`(目录-skill 用 install 同一 distributable
150
+ * filter;单文件-skill copyFile 到 `<tmp>/<name>.md`,使 `<hash>` 恒为目录、消歧),再原子
151
+ * `rename` 到 `<hash>`;rename 撞 EEXIST/ENOTEMPTY(并发已落同 hash)即删临时复用。
152
+ * 物化只发生一次/variant(resolveArtifacts 解析期),不随 sample 数放大。
153
+ */
154
+ export function materializeIsolatedCopy(localRoot, isDirectorySkill, name) {
155
+ const root = treesDir();
156
+ const contentHash = hashArtifactSource(localRoot, isDirectorySkill);
157
+ const target = join(root, contentHash);
158
+ const copyRoot = isDirectorySkill ? target : join(target, `${name}.md`);
159
+ if (existsSync(target)) {
160
+ // 命中:零 copy。utimes 触碰 mtime 作 LRU 标记 + 落 pid 占用锁(本进程将拿它当 cwd,prune 不许删)。
161
+ try {
162
+ const now = new Date();
163
+ utimesSync(target, now, now);
164
+ }
165
+ catch {
166
+ // 触碰失败不致命
167
+ }
168
+ markCopyInUse(contentHash);
169
+ return { copyRoot, contentHash, isDirectorySkill };
170
+ }
171
+ mkdirSync(root, { recursive: true });
172
+ const tmp = mkdtempSync(join(root, '.tmp-'));
173
+ try {
174
+ if (isDirectorySkill) {
175
+ cpSync(localRoot, tmp, { recursive: true, filter: distributableCopyFilter(localRoot) });
176
+ }
177
+ else {
178
+ copyFileSync(localRoot, join(tmp, `${name}.md`));
179
+ }
180
+ renameSync(tmp, target);
181
+ markCopyInUse(contentHash); // 先落占用锁,再 prune —— 保证本进程刚落的副本绝不被自己的 prune 删
182
+ pruneTreesDir(); // 回收超限的旧副本(跳过活进程占用 + grace)
183
+ return { copyRoot, contentHash, isDirectorySkill };
184
+ }
185
+ catch (err) {
186
+ rmSync(tmp, { recursive: true, force: true });
187
+ // 并发竞态:另一进程已先 rename 到 target(非空)→ EEXIST/ENOTEMPTY;内容相同,复用。
188
+ const code = err.code;
189
+ if (code === 'EEXIST' || code === 'ENOTEMPTY')
190
+ return { copyRoot, contentHash, isDirectorySkill };
191
+ throw err;
192
+ }
193
+ }