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
@@ -1,40 +1,56 @@
1
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { dirname, join, resolve, sep } from 'node:path';
4
- import { isDistributablePath } from '../managed/index.js';
5
- import { classifyGitSkillRef, parseGitInput, resolveGitRepoContext, gitJoin, gitLsTreeBlobs, gitShowBytes, skillNameFromPath } from './skill-loader.js';
6
- export class SourceResolveError extends Error {
7
- messageKey;
8
- params;
9
- constructor(messageKey, params = {}) {
10
- super(messageKey);
11
- this.name = 'SourceResolveError';
12
- this.messageKey = messageKey;
13
- this.params = params;
14
- }
15
- }
16
- const noop = () => { };
1
+ import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { classifyGitSkillRef, parseGitInput, resolveGitRepoContext, materializeGitSkillTree, skillNameFromPath, SourceResolveError, fetchRemoteGitRef } from './skill-loader.js';
17
4
  /**
18
- * 校验 git tree 条目路径在物化目标内,越界即 fail closed(抛 SourceResolveError)。
19
- * 双保险:既显式拒 `..` / 空段(git tree 可被手工构造出名为 `..` 的子树),也用 resolve 兜底
20
- * 确认落点仍在 temp 之下(绝对路径 / 符号化逃逸)。绝不静默跳过——跳过会让物化树与真实树发散。
5
+ * 安装源解析器 —— 把"各种源"物化成统一的本地形态,让 install / managed 主干**源无关**。
6
+ * 新增一种源 = 加一个分支,install.ts 不动。本模块不依赖 CLI(不打印、不 tCli):错误以
7
+ * `SourceResolveError(messageKey)` 抛出,由调用方(install)映射成本地化文案。
8
+ *
9
+ * 当前支持:`file`(本地路径)、`git`(当前仓库的某个 ref)、远端 git(URL + ref,fetch 到临时 bare)。
10
+ * 三者沿同一 `ResolvedSource` 契约,install / managed 主干源无关。
21
11
  */
22
- function assertContainedRelPath(temp, relPath) {
23
- const segments = relPath.split('/');
24
- if (segments.some((s) => s === '' || s === '.' || s === '..')) {
25
- throw new SourceResolveError('cli.install.git_unsafe_path', { path: relPath });
26
- }
27
- const root = resolve(temp);
28
- const dest = resolve(temp, relPath);
29
- if (dest !== root && !dest.startsWith(root + sep)) {
30
- throw new SourceResolveError('cli.install.git_unsafe_path', { path: relPath });
31
- }
32
- }
12
+ // SourceResolveError 住在 skill-loader(让共享 materializeGitSkillTree 能抛它而不成环);此处 re-export
13
+ // 保持 install 既有 import 不破。
14
+ export { SourceResolveError } from './skill-loader.js';
15
+ const noop = () => { };
33
16
  export function resolveInstallSource(input) {
34
17
  if (input.startsWith('git:'))
35
18
  return resolveGitSource(input);
36
19
  return resolveFileSource(input);
37
20
  }
21
+ /**
22
+ * 远端 git 源:`<url>` + `<ref>` + repo 相对 `<spec>`(三者结构化传入,URL 不经任何字符串切分,
23
+ * 避开 `parseGitInput` 的 `:` 与 `parseVariantCwd` 的 `@`)。fetch 到临时 bare → 复用既有
24
+ * classifyGitSkillRef / materializeGitSkillTree(repoRoot 换成 bare)。locator 落 `git+<url>@<sha>:<spec>`
25
+ * 仅作记录身份;url 另以结构化字段携带,供 drift 重取。失败 fail-closed 并清临时资源。
26
+ */
27
+ export function resolveRemoteGitSource(url, ref, spec) {
28
+ const checkout = fetchRemoteGitRef(url, ref);
29
+ try {
30
+ // 远端 URL 指向仓库根,spec 是仓库相对路径 → gitRelDir 为空。
31
+ const resolved = classifyGitSkillRef(checkout.ref, '', spec, checkout.repoRoot);
32
+ if (!resolved)
33
+ throw new SourceResolveError('cli.install.remote_skill_not_found', { url, ref: checkout.ref, name: spec });
34
+ const mat = materializeGitSkillTree(checkout.ref, resolved, checkout.repoRoot);
35
+ return {
36
+ sourceKind: 'git',
37
+ localRoot: mat.localRoot,
38
+ name: mat.name,
39
+ isDirectorySkill: mat.isDirectorySkill,
40
+ locator: `git+${url}@${checkout.ref}:${spec}`,
41
+ ref: checkout.ref,
42
+ url,
43
+ cleanup: () => {
44
+ mat.cleanup();
45
+ checkout.cleanup();
46
+ },
47
+ };
48
+ }
49
+ catch (err) {
50
+ checkout.cleanup();
51
+ throw err;
52
+ }
53
+ }
38
54
  function resolveFileSource(input) {
39
55
  const abs = resolve(input);
40
56
  if (!existsSync(abs))
@@ -74,52 +90,16 @@ function resolveGitSource(input) {
74
90
  if (!resolved) {
75
91
  throw new SourceResolveError('cli.install.git_skill_not_found', { ref, name: spec });
76
92
  }
77
- const temp = mkdtempSync(join(tmpdir(), 'omk-install-git-'));
78
- const cleanup = () => {
79
- try {
80
- rmSync(temp, { recursive: true, force: true });
81
- }
82
- catch {
83
- // 临时目录清理失败不致命
84
- }
93
+ // 物化复用共享 materializeGitSkillTree(install 与 eval 同一条物化路径);install 只需在其上补
94
+ // locator / sourceKind 包成 ResolvedSource。
95
+ const mat = materializeGitSkillTree(ref, resolved, ctx.repoRoot);
96
+ return {
97
+ sourceKind: 'git',
98
+ localRoot: mat.localRoot,
99
+ name: mat.name,
100
+ isDirectorySkill: mat.isDirectorySkill,
101
+ locator: `git:${ref}:${spec}`,
102
+ ref,
103
+ cleanup: mat.cleanup,
85
104
  };
86
- try {
87
- const locator = `git:${ref}:${spec}`;
88
- if (resolved.isDir) {
89
- for (const entry of gitLsTreeBlobs(ref, resolved.treePath, ctx.repoRoot)) {
90
- // 安全边界 fail closed:git tree 可被 git mktree 手工构造出名为 `..` 的子树,`ls-tree -r` 会
91
- // 吐 `../evil.txt`,`join(temp, ...)` 会逃出临时目录写盘、cleanup 也删不掉。任一越界路径(.. /
92
- // 绝对路径 / 空段 / 解析后不在 temp 内)直接抛错,而非静默跳过——静默跳过会让物化树与真实
93
- // git tree / hash / 分发树发散。正常 checkout 永不触发。
94
- assertContainedRelPath(temp, entry.path);
95
- if (entry.mode === '120000' || entry.mode === '160000')
96
- continue; // 跳过软链 / submodule(与本地分发一致)
97
- if (!isDistributablePath(entry.path.split('/')))
98
- continue; // 排除 .omk/.git/evolve 等
99
- const bytes = gitShowBytes(ref, gitJoin(resolved.treePath, entry.path), ctx.repoRoot);
100
- if (!bytes)
101
- continue;
102
- const dest = join(temp, entry.path);
103
- mkdirSync(dirname(dest), { recursive: true });
104
- // 保留可执行位(与本地 cpSync 一致);其余 0644。
105
- writeFileSync(dest, bytes, { mode: entry.mode === '100755' ? 0o755 : 0o644 });
106
- }
107
- // 纵深防御:classify 与物化用的是两条 git 路径(gitShowFile vs ls-tree),万一发散(如 treePath 退化)
108
- // 导致空树,这里失败而非静默分发一个空 skill。
109
- if (!existsSync(join(temp, 'SKILL.md'))) {
110
- throw new SourceResolveError('cli.install.git_skill_not_found', { ref, name: spec });
111
- }
112
- return { sourceKind: 'git', localRoot: temp, name: resolved.name, isDirectorySkill: true, locator, ref, cleanup };
113
- }
114
- const bytes = gitShowBytes(ref, resolved.fileSkillPath, ctx.repoRoot);
115
- if (!bytes)
116
- throw new SourceResolveError('cli.install.git_skill_not_found', { ref, name: spec });
117
- const dest = join(temp, `${resolved.name}.md`);
118
- writeFileSync(dest, bytes);
119
- return { sourceKind: 'git', localRoot: dest, name: resolved.name, isDirectorySkill: false, locator, ref, cleanup };
120
- }
121
- catch (err) {
122
- cleanup();
123
- throw err;
124
- }
125
105
  }
@@ -0,0 +1,22 @@
1
+ import type { EvaluationReport, ManagedEvidenceRef } from '../types/index.js';
2
+ /**
3
+ * 为某个变体组装一条 evidence ref;变体无真实内容(baseline / no-skill / 缺 hash)→ null。
4
+ * `verdict` 由调用方传入(CLI 已 computeVerdict,避免在此重算)。
5
+ */
6
+ export declare function buildEvidenceRef(report: EvaluationReport, variant: string, verdict: string, recordedAt: string): ManagedEvidenceRef | null;
7
+ export interface RecordedEvidence {
8
+ recordId: string;
9
+ name: string;
10
+ contentHash: string;
11
+ /** true ⇒ evidence.contentHash 等于记录当前 contentHash → deriveManagedState 计为当前证据 →
12
+ * measurable。false ⇒ 测的是旧内容(已 drift),证据留存但不绑当前版本。 */
13
+ bound: boolean;
14
+ }
15
+ /**
16
+ * 驱动:对每个能在报告里(按 contentHash 主键 / 同名回退)匹配到被测 variant 的**已纳管**记录,
17
+ * 追加一条 evidence。返回实际写入的清单(供 CLI 提示,`name` 取受管记录名而非 variant 键)。
18
+ * 无任何记录匹配 → 返回空(常见的非管理用户场景,静默无副作用)。
19
+ */
20
+ export declare function recordEvalEvidence(report: EvaluationReport, verdict: string, recordedAt: string, opts?: {
21
+ dir?: string;
22
+ }): RecordedEvidence[];
@@ -0,0 +1,143 @@
1
+ /**
2
+ * eval → managed evidence 写入(承接 #203 管理支柱 / #214 验收第 3 条)。
3
+ *
4
+ * 一次 `omk eval` 跑完,把结果落成一条 `ManagedEvidenceRef` 追加进**已纳管**记录的 evidence[],
5
+ * 让 skill 从 `installed` 走到 `measurable`(由 `deriveManagedState` 读时推导)。承重前提是 #214/#218
6
+ * 已把 install 与 eval 的内容指纹统一到同一空间(整树哈),所以 `report.artifactHashes[variant]` 与
7
+ * `record.contentHash` 可直接相等比对、evidence 绑得上。
8
+ *
9
+ * 三条设计取舍(对应 issue #221 的"待定决策"):
10
+ * - **触发**:eval 完成**自动**写,但只写**已存在**的受管记录 —— install 是显式 opt-in,未纳管的
11
+ * skill 永不被凭空建记录(零副作用惊吓)。CLI 另给 `--no-evidence` 关闭。
12
+ * - **多对一**:append-only + 按 (reportId, contentHash) 去重;保留全部历史条目,当前有效性仍由
13
+ * `deriveManagedState` 按 contentHash 匹配裁定(重装新内容后旧证据留存供回滚,却不让新内容显得已测)。
14
+ * - **跨源**:用三级身份消歧把被测 variant 对到受管记录(install 与 eval 命名不一致:记录名是
15
+ * skill 短名 `review`,而 eval 报告 variant key 可能是整串表达式 `git:HEAD:skills/review`、
16
+ * eval.yaml 别名 `candidate`、blind 模式的 `A`/`B`)。`applyBlindMode` 盲化 `variants` 但**不**动
17
+ * `artifactHashes` / `variantConfigs` 的键面,故三级都按真实键工作:
18
+ * (a) 显式**同名** variant —— 最强身份(本地 `--treatment <name>` 与 drift);
19
+ * (b) **结构化源匹配** —— `variantConfigs[].locator(+ref)` 与 `record.source` 对齐(git / 远端 /
20
+ * 别名),即便内容撞哈也能精确消歧;
21
+ * (c) **纯 contentHash 回退** —— 仅当该内容哈在受管记录中**唯一**才用。否则两条不同记录恰好当前
22
+ * 内容相同(同模板复制 / 刚装)时,只测了一条的 report 会把同哈的另一条也推成 measurable ——
23
+ * 唯一性闸门挡掉这种越权写入(撞哈又非同名 / 无结构化身份 → 跳过,不乱绑)。
24
+ *
25
+ * bundle 按 evidence-gated-management.md §5 denormalize 进记录(reportId / contentHash /
26
+ * verdict / sampleCoverage / comparability),不依赖 report 文件仍在盘。
27
+ */
28
+ import { basename, dirname } from 'node:path';
29
+ import { hashString } from '../eval-core/evaluation-reporting.js';
30
+ import { loadAllManagedRecords, appendManagedEvidence, managedDir, resolveManagedDir } from './store.js';
31
+ /** baseline / 无 skill 变体的 artifactHash 哨兵(见 report.ts artifactHashes 注释)——不产证据。 */
32
+ const NO_SKILL = 'no-skill';
33
+ /** 样本集覆盖摘要:report 的 sampleHashes 排序后取一个稳定 digest(同一样本集 ⇒ 同 hash)。
34
+ * 缺 sampleHashes(旧报告 / --dry-run)→ undefined,bundle 仍含其余三项 mandatory。 */
35
+ function sampleCoverage(report) {
36
+ const sh = report.meta?.sampleHashes;
37
+ if (!sh)
38
+ return undefined;
39
+ const entries = Object.entries(sh).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
40
+ return { count: entries.length, hash: hashString(JSON.stringify(entries)) };
41
+ }
42
+ /**
43
+ * 为某个变体组装一条 evidence ref;变体无真实内容(baseline / no-skill / 缺 hash)→ null。
44
+ * `verdict` 由调用方传入(CLI 已 computeVerdict,避免在此重算)。
45
+ */
46
+ export function buildEvidenceRef(report, variant, verdict, recordedAt) {
47
+ const contentHash = report.meta?.artifactHashes?.[variant];
48
+ if (!contentHash || contentHash === NO_SKILL)
49
+ return null;
50
+ const meta = report.meta;
51
+ const cov = sampleCoverage(report);
52
+ return {
53
+ reportId: report.id,
54
+ contentHash,
55
+ recordedAt,
56
+ verdict,
57
+ ...(cov ? { sampleCoverage: cov } : {}),
58
+ comparability: {
59
+ cliVersion: meta.cliVersion,
60
+ ...(meta.judgePromptHash ? { judgePromptHash: meta.judgePromptHash } : {}),
61
+ ...(meta.debiasMode ? { debiasMode: meta.debiasMode } : {}),
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * variantConfig 的源身份是否与受管记录 source 对得上 —— install 与 eval 两侧 locator **口径不同**,
67
+ * 必须先归一化再比,不能裸字符串相等(否则同 hash 多记录场景下正确的本地 git / 目录-skill 也会因
68
+ * locator 不等而漏写):
69
+ * - 直接相等:远端 git(两侧都 `git+<url>@<sha>:<spec>`)、本地 file-skill(两侧都是 .md 路径);
70
+ * - 本地 git:install 记完整身份串 `git:<ref>:<spec>`,eval 把 spec 落 `cfg.locator`、ref 另存 `cfg.ref`
71
+ * → 归一化成 `git:<cfg.ref>:<cfg.locator>` 再比;
72
+ * - 本地目录-skill:install 记目录根,eval 记 `<dir>/SKILL.md` → 比 `dirname(cfg.locator)`。
73
+ */
74
+ function sourceMatches(cfg, source) {
75
+ if (!cfg.locator)
76
+ return false;
77
+ if (cfg.locator === source.locator)
78
+ return true;
79
+ if (source.sourceKind === 'git' && cfg.ref && `git:${cfg.ref}:${cfg.locator}` === source.locator)
80
+ return true;
81
+ if (source.isDirectorySkill && basename(cfg.locator) === 'SKILL.md' && dirname(cfg.locator) === source.locator)
82
+ return true;
83
+ return false;
84
+ }
85
+ /**
86
+ * 给某条受管记录在报告里找该绑定的 variant key,三级身份消歧(见文件头「跨源」)。
87
+ * `hashIsUnique` 由调用方按全体受管记录的 contentHash 计数提供 —— 纯 hash 回退的越权闸门。
88
+ */
89
+ function matchVariantKey(report, record, hashIsUnique) {
90
+ const hashes = report.meta?.artifactHashes ?? {};
91
+ // (a) 显式同名 variant(本地 --treatment <name> / drift)。
92
+ if (hashes[record.name] && hashes[record.name] !== NO_SKILL)
93
+ return record.name;
94
+ // (b) 结构化源匹配:variantConfig.locator(+ref) 对齐记录 source(git / 远端 / 别名)。
95
+ for (const cfg of report.meta?.variantConfigs ?? []) {
96
+ const h = hashes[cfg.variant];
97
+ if (!h || h === NO_SKILL)
98
+ continue;
99
+ if (sourceMatches(cfg, record.source))
100
+ return cfg.variant;
101
+ }
102
+ // (c) 纯 contentHash 回退 —— 仅当该哈在受管记录中唯一(否则撞哈会越权写到没测的记录)。
103
+ if (hashIsUnique(record.contentHash)) {
104
+ for (const [key, hash] of Object.entries(hashes)) {
105
+ if (hash !== NO_SKILL && hash === record.contentHash)
106
+ return key;
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+ /**
112
+ * 驱动:对每个能在报告里(按 contentHash 主键 / 同名回退)匹配到被测 variant 的**已纳管**记录,
113
+ * 追加一条 evidence。返回实际写入的清单(供 CLI 提示,`name` 取受管记录名而非 variant 键)。
114
+ * 无任何记录匹配 → 返回空(常见的非管理用户场景,静默无副作用)。
115
+ */
116
+ export function recordEvalEvidence(report, verdict, recordedAt, opts = {}) {
117
+ const out = [];
118
+ if (Object.keys(report.meta?.artifactHashes ?? {}).length === 0)
119
+ return out;
120
+ // 写回读方实际取记录的同一目录(project→global 同口径)。
121
+ const dir = resolveManagedDir(opts.dir ?? managedDir());
122
+ const records = loadAllManagedRecords(dir);
123
+ if (records.length === 0)
124
+ return out;
125
+ // 全体记录的 contentHash 计数 —— 纯 hash 回退只在唯一时放行(挡同内容多记录的越权写)。
126
+ const hashCount = new Map();
127
+ for (const r of records)
128
+ hashCount.set(r.contentHash, (hashCount.get(r.contentHash) ?? 0) + 1);
129
+ const hashIsUnique = (hash) => (hashCount.get(hash) ?? 0) === 1;
130
+ for (const rec of records) {
131
+ const variantKey = matchVariantKey(report, rec, hashIsUnique);
132
+ if (!variantKey)
133
+ continue;
134
+ const ref = buildEvidenceRef(report, variantKey, verdict, recordedAt);
135
+ if (!ref)
136
+ continue;
137
+ const merged = appendManagedEvidence(dir, rec.id, ref);
138
+ if (!merged)
139
+ continue;
140
+ out.push({ recordId: rec.id, name: rec.name, contentHash: ref.contentHash, bound: ref.contentHash === merged.contentHash });
141
+ }
142
+ return out;
143
+ }
@@ -3,3 +3,4 @@
3
3
  * 不直接依赖内部文件布局。
4
4
  */
5
5
  export * from './store.js';
6
+ export * from './evidence.js';
@@ -3,3 +3,4 @@
3
3
  * 不直接依赖内部文件布局。
4
4
  */
5
5
  export * from './store.js';
6
+ export * from './evidence.js';
@@ -1,4 +1,4 @@
1
- import type { ArtifactKind, DeriveManagedStateInput, DerivedManagedState, ManagedArtifactRecord, ManagedArtifactSource, ManagedDistributionTarget } from '../types/index.js';
1
+ import type { ArtifactKind, DeriveManagedStateInput, DerivedManagedState, ManagedArtifactRecord, ManagedArtifactSource, ManagedDistributionTarget, ManagedEvidenceRef } from '../types/index.js';
2
2
  /**
3
3
  * 受管记录的 per-record 文件存储。一条记录一个 `.omk/managed/<id>.json`,镜像 report-store 的
4
4
  * 成熟模式(原子 tmp+rename):每次 install 只碰自己那个文件,independent write、不丢别人、
@@ -14,28 +14,16 @@ export declare function globalManagedDir(): string;
14
14
  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
- /**
18
- * 给定相对源根的路径分段(空数组 = 源根本身),判断是否进可分发树。hash 的 walk、copy 的 filter、
19
- * git 物化共用此一处,保证三者完全一致。
20
- *
21
- * 关键:检查**每一段**而非只看叶子。本地 walk / cpSync 是逐层下降、命中目录即剪枝,只看叶子也够;
22
- * 但 git ls-tree 给的是**扁平路径**(如 `.omk/samples.json`),只看叶子 `samples.json` 会让 `.omk`
23
- * 内容漏过。故:任一段命中全局排除即排除;首段命中 root-only(evolve)即排除(嵌套同名是合法资产)。
24
- */
25
- export declare function isDistributablePath(segments: string[]): boolean;
26
- /**
27
- * artifact 内容 hash —— drift baseline 与 evidence 绑定的依据。
28
- * - 文件-skill:单个 .md 的字节;
29
- * - 目录-skill:覆盖**整棵可分发目录树**(SKILL.md + references/ 等资产,但排除 .omk / .git /
30
- * evolve 等评测迭代产物),按相对路径排序后把每个文件的`路径 + 字节长度 + 内容`喂进同一个
31
- * sha256 —— 改任意资产都会令 hash 变化、drift 不漏;只补样本则 hash 不动。
32
- * 用 createHash 直接喂 Buffer(字节级,二进制资产也稳;分隔符是运行时字节,源码里不引入任何不可见字符)。
33
- * 读时(未来 list / drift 检查)用同一函数重算比对。
34
- */
35
- export declare function hashArtifactSource(source: string, isDirectorySkill: boolean): string;
17
+ export { hashArtifactSource, isDistributablePath, distributableCopyFilter } from '../inputs/content-hash.js';
36
18
  export declare function loadManagedRecord(dir: string, id: string): ManagedArtifactRecord | null;
37
19
  /** 读全部记录。项目目录空 → 兜底全局(镜像 observe inbox 的 project→global)。 */
38
20
  export declare function loadAllManagedRecords(dir?: string): ManagedArtifactRecord[];
21
+ /**
22
+ * 哪个 managed 目录是权威(有记录的那个):项目目录非空取项目,否则全局非空取全局,都空回项目。
23
+ * 与 `loadAllManagedRecords` 的 project→global 回退**同口径** —— 写方(append evidence)据此写回
24
+ * 读方实际取记录的同一目录,避免"读全局、写项目"把证据落到空目录。
25
+ */
26
+ export declare function resolveManagedDir(dir?: string): string;
39
27
  /**
40
28
  * 纯合并逻辑(无 IO):install 写的是事实,绝不动 evidence/decisions(那是 eval/promote 的地盘)。
41
29
  * - distribution 按 path 去重(同路径以新值替换);
@@ -48,6 +36,14 @@ export declare function loadAllManagedRecords(dir?: string): ManagedArtifactReco
48
36
  export declare function mergeManagedRecord(prev: ManagedArtifactRecord | null, next: ManagedArtifactRecord): ManagedArtifactRecord;
49
37
  /** 读旧记录 → 合并 → 原子 tmp+rename 只写该 id 的文件。返回合并后记录。 */
50
38
  export declare function upsertManagedRecord(dir: string, record: ManagedArtifactRecord): ManagedArtifactRecord;
39
+ /**
40
+ * 追加一条评测证据(append-only)。与 install 的 upsert 路径相反——`mergeManagedRecord` 刻意**保留旧
41
+ * evidence、丢弃 next.evidence**(install 只写事实),所以证据写入不能走 upsert,必须独立 load→push→
42
+ * 原子重写。按 `(reportId, contentHash)` 去重:重跑同一份 eval 不堆重复条目(reportId 含运行身份,
43
+ * 同内容重测会是新 reportId → 新条目,正是要的版本史)。记录不存在(未 install / 名字不匹配)返回
44
+ * null —— 这是"管理是 install 显式 opt-in"的体现:eval 绝不为未纳管的 skill 凭空建记录。
45
+ */
46
+ export declare function appendManagedEvidence(dir: string, recordId: string, evidence: ManagedEvidenceRef): ManagedArtifactRecord | null;
51
47
  /**
52
48
  * 统一的记录构造点——所有消费方(CLI / 未来 server / SDK)经此组装,保证 schema 一致。
53
49
  * 只接收事实(身份、源、hash、分发落点);evidence/decisions 恒为空(eval/promote 的地盘)。
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { join, normalize } from 'node:path';
@@ -26,81 +25,9 @@ export function recordPath(dir, id) {
26
25
  export function managedRecordId(kind, name) {
27
26
  return hashString(`${kind}|${name}`);
28
27
  }
29
- /**
30
- * 不进 artifact「可分发树」的条目 —— omk 的评测 / 迭代 / VCS / 系统产物,既不该被拷进 agent
31
- * skill 目录,也不该计入 artifact contentHash。区分两类语义,避免误伤合法嵌套资产:
32
- * - **任意层级排除**:隐藏元数据 / VCS / 系统 / 依赖目录,任何深度出现都是噪声
33
- * (`.omk` = samples / managed / observations;`.git`;`node_modules`;OS 垃圾);
34
- * - **仅源根第一层排除**:omk 保留的工作目录,只在 skill 根有保留语义,嵌套同名是用户合法资产
35
- * (`evolve` 是 `<skillDir>/evolve/` 候选快照;但 `references/evolve/guide.md` 应正常分发并计入 hash)。
36
- * spec 里 artifact content hash 与 sample-set hash 是分开的证据轴,故只补样本(.omk)不该改 hash。
37
- */
38
- const GLOBAL_EXCLUDED_NAMES = new Set(['.omk', '.git', 'node_modules', '.DS_Store', 'Thumbs.db']);
39
- const ROOT_ONLY_EXCLUDED_NAMES = new Set(['evolve']);
40
- /**
41
- * 给定相对源根的路径分段(空数组 = 源根本身),判断是否进可分发树。hash 的 walk、copy 的 filter、
42
- * git 物化共用此一处,保证三者完全一致。
43
- *
44
- * 关键:检查**每一段**而非只看叶子。本地 walk / cpSync 是逐层下降、命中目录即剪枝,只看叶子也够;
45
- * 但 git ls-tree 给的是**扁平路径**(如 `.omk/samples.json`),只看叶子 `samples.json` 会让 `.omk`
46
- * 内容漏过。故:任一段命中全局排除即排除;首段命中 root-only(evolve)即排除(嵌套同名是合法资产)。
47
- */
48
- export function isDistributablePath(segments) {
49
- if (segments.length === 0)
50
- return true; // 源根永远算
51
- for (const seg of segments) {
52
- if (GLOBAL_EXCLUDED_NAMES.has(seg))
53
- return false;
54
- }
55
- if (ROOT_ONLY_EXCLUDED_NAMES.has(segments[0]))
56
- return false;
57
- return true;
58
- }
59
- /**
60
- * artifact 内容 hash —— drift baseline 与 evidence 绑定的依据。
61
- * - 文件-skill:单个 .md 的字节;
62
- * - 目录-skill:覆盖**整棵可分发目录树**(SKILL.md + references/ 等资产,但排除 .omk / .git /
63
- * evolve 等评测迭代产物),按相对路径排序后把每个文件的`路径 + 字节长度 + 内容`喂进同一个
64
- * sha256 —— 改任意资产都会令 hash 变化、drift 不漏;只补样本则 hash 不动。
65
- * 用 createHash 直接喂 Buffer(字节级,二进制资产也稳;分隔符是运行时字节,源码里不引入任何不可见字符)。
66
- * 读时(未来 list / drift 检查)用同一函数重算比对。
67
- */
68
- export function hashArtifactSource(source, isDirectorySkill) {
69
- if (!isDirectorySkill) {
70
- return createHash('sha256').update(readFileSync(source)).digest('hex').slice(0, 12);
71
- }
72
- const rels = [];
73
- const walk = (dir, segments) => {
74
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
75
- const segs = [...segments, entry.name];
76
- if (!isDistributablePath(segs))
77
- continue;
78
- // 软链既非 isFile 也非 isDirectory,天然跳过 —— 与 copyArtifactToTarget 的 filter 一致
79
- // (hash 覆盖的 == 分发出去的),避免软链目标改变却不触发 drift,也回避软链环。
80
- if (entry.isDirectory())
81
- walk(join(dir, entry.name), segs);
82
- else if (entry.isFile())
83
- rels.push(segs.join('/'));
84
- }
85
- };
86
- walk(source, []);
87
- rels.sort();
88
- const h = createHash('sha256');
89
- const sep = Buffer.from([0]);
90
- for (const rel of rels) {
91
- const content = readFileSync(join(source, rel));
92
- // 路径与内容都做长度前缀,彻底排除"不同树拼出同一串"的歧义(文件名虽不含 NUL,核心层仍按可注入防)。
93
- h.update(String(Buffer.byteLength(rel)));
94
- h.update(sep);
95
- h.update(rel);
96
- h.update(sep);
97
- h.update(String(content.length));
98
- h.update(sep);
99
- h.update(content);
100
- h.update(sep);
101
- }
102
- return h.digest('hex').slice(0, 12);
103
- }
28
+ // 内容指纹工具已下沉到 inputs 层(install 与 eval 共用同一处,保证指纹同空间);此处 re-export
29
+ // 保持 managed 公共入口不破(install 仍从 managed 取 hashArtifactSource / isDistributablePath)。
30
+ export { hashArtifactSource, isDistributablePath, distributableCopyFilter } from '../inputs/content-hash.js';
104
31
  function isStringField(v) {
105
32
  return typeof v === 'string';
106
33
  }
@@ -175,6 +102,19 @@ export function loadAllManagedRecords(dir = managedDir()) {
175
102
  }
176
103
  return local;
177
104
  }
105
+ /**
106
+ * 哪个 managed 目录是权威(有记录的那个):项目目录非空取项目,否则全局非空取全局,都空回项目。
107
+ * 与 `loadAllManagedRecords` 的 project→global 回退**同口径** —— 写方(append evidence)据此写回
108
+ * 读方实际取记录的同一目录,避免"读全局、写项目"把证据落到空目录。
109
+ */
110
+ export function resolveManagedDir(dir = managedDir()) {
111
+ if (readRecordsFromDir(dir).length > 0)
112
+ return dir;
113
+ const global = globalManagedDir();
114
+ if (dir !== global && readRecordsFromDir(global).length > 0)
115
+ return global;
116
+ return dir;
117
+ }
178
118
  /**
179
119
  * 纯合并逻辑(无 IO):install 写的是事实,绝不动 evidence/decisions(那是 eval/promote 的地盘)。
180
120
  * - distribution 按 path 去重(同路径以新值替换);
@@ -217,6 +157,30 @@ export function upsertManagedRecord(dir, record) {
217
157
  renameSync(tmp, path);
218
158
  return merged;
219
159
  }
160
+ /**
161
+ * 追加一条评测证据(append-only)。与 install 的 upsert 路径相反——`mergeManagedRecord` 刻意**保留旧
162
+ * evidence、丢弃 next.evidence**(install 只写事实),所以证据写入不能走 upsert,必须独立 load→push→
163
+ * 原子重写。按 `(reportId, contentHash)` 去重:重跑同一份 eval 不堆重复条目(reportId 含运行身份,
164
+ * 同内容重测会是新 reportId → 新条目,正是要的版本史)。记录不存在(未 install / 名字不匹配)返回
165
+ * null —— 这是"管理是 install 显式 opt-in"的体现:eval 绝不为未纳管的 skill 凭空建记录。
166
+ */
167
+ export function appendManagedEvidence(dir, recordId, evidence) {
168
+ const prev = loadManagedRecord(dir, recordId);
169
+ if (!prev)
170
+ return null;
171
+ const dup = prev.evidence.some((e) => e.reportId === evidence.reportId && e.contentHash === evidence.contentHash);
172
+ const merged = dup
173
+ ? prev
174
+ : { ...prev, evidence: [...prev.evidence, evidence] };
175
+ if (!dup) {
176
+ mkdirSync(dir, { recursive: true });
177
+ const path = recordPath(dir, recordId);
178
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
179
+ writeFileSync(tmp, JSON.stringify(merged, null, 2));
180
+ renameSync(tmp, path);
181
+ }
182
+ return merged;
183
+ }
220
184
  /**
221
185
  * 统一的记录构造点——所有消费方(CLI / 未来 server / SDK)经此组装,保证 schema 一致。
222
186
  * 只接收事实(身份、源、hash、分发落点);evidence/decisions 恒为空(eval/promote 的地盘)。
@@ -159,7 +159,7 @@ export const I18N = {
159
159
  diffColCoverage: '覆盖',
160
160
  viewTrendLink: '查看趋势 →',
161
161
  artifactHashLabel: '版本指纹',
162
- artifactHashTooltip: 'skill 文件内容的 SHA-256 前 12 位(不含路径/时间/git),用于辨别报告对应哪一版 skill;同文件多次跑指纹不变,改一字节就变——防止"改动效果"和"随机波动"混淆',
162
+ artifactHashTooltip: 'skill 内容指纹的 SHA-256 前 12 位(不含路径/时间/git):目录-skill(本地或 git)覆盖整棵可分发树(SKILL.md + references/ 资产,排除 .omk/.git/node_modules/evolve;改任意资产都变,git 源经隔离副本物化、整树暴露给 executor),单文件-skill 取该 .md 字节;用于辨别报告对应哪一版 skill,同输入指纹不变——防止"改动效果"和"随机波动"混淆',
163
163
  switchLang: '英文',
164
164
  },
165
165
  en: {
@@ -272,7 +272,7 @@ export const I18N = {
272
272
  diffColCoverage: 'Coverage',
273
273
  viewTrendLink: 'trend →',
274
274
  artifactHashLabel: 'Version fingerprint',
275
- artifactHashTooltip: 'First 12 hex chars of SHA-256 over the skill file content (content-only: no path/time/git); identifies which version of the skill this report ran — same file = same fingerprint, any byte change = different fingerprint. Keeps "intentional change" separate from "random variance"',
275
+ artifactHashTooltip: 'First 12 hex chars of SHA-256 over the skill content (content-only: no path/time/git): a directory-skill (local or git) covers the whole distributable tree (SKILL.md + references/ assets, excluding .omk/.git/node_modules/evolve; any asset change flips it; git sources are materialized into an isolated copy whose whole tree is exposed to the executor), a file-skill covers the single .md bytes. Identifies which version of the skill this report ran — same input = same fingerprint. Keeps "intentional change" separate from "random variance"',
276
276
  switchLang: 'Chinese',
277
277
  },
278
278
  };
@@ -136,10 +136,12 @@ export interface Artifact {
136
136
  kind: ArtifactKind;
137
137
  source: 'baseline' | 'variant-name' | 'file-path' | 'git' | 'inline' | 'custom';
138
138
  content: string | null;
139
+ contentHash?: string;
139
140
  locator?: string;
140
141
  ref?: string;
141
142
  cwd?: string;
142
143
  skillRoot?: string;
144
+ execRoot?: string;
143
145
  experimentRole?: ExperimentRole;
144
146
  allowedSkills?: string[];
145
147
  metadata?: Record<string, unknown>;
@@ -160,17 +162,26 @@ export interface VariantConfig {
160
162
  ref?: string;
161
163
  allowedSkills?: string[];
162
164
  }
165
+ /** 远端 git 源的结构化引用 —— url/ref/spec 分字段,永不拼成单串再 split(避开 parseGitInput 的 `:`
166
+ * 与 parseVariantCwd 的 `@`)。eval 经 eval.yaml 结构化携带,install 经 --git-url/--git-ref。 */
167
+ export interface RemoteGitRef {
168
+ url: string;
169
+ ref?: string;
170
+ spec: string;
171
+ }
163
172
  export interface VariantSpec {
164
173
  name: string;
165
174
  role: ExperimentRole;
166
175
  expr: string;
176
+ git?: RemoteGitRef;
167
177
  cwd?: string;
168
178
  allowedSkills?: string[];
169
179
  }
170
180
  export interface EvalConfigVariant {
171
181
  name: string;
172
182
  role: ExperimentRole;
173
- artifact: string;
183
+ artifact?: string;
184
+ git?: RemoteGitRef;
174
185
  cwd?: string;
175
186
  allowedSkills?: string[];
176
187
  }
@@ -21,13 +21,35 @@ export interface ManagedDistributionTarget {
21
21
  contentHash: string;
22
22
  copiedAt: string;
23
23
  }
24
- /** 指向一份 Report 的引用——不是 verdict 本体。install 时为空,eval/promote 追加。 */
24
+ /** 指向一份 Report 的引用 + 该 report 的最小可比性快照——不是 verdict 本体。install 时为空,
25
+ * eval 完成后追加(见 `src/managed/evidence.ts`)。
26
+ *
27
+ * `reportId` / `contentHash` 是承重的两根(读时门控只认这俩,validator 也只硬查这俩);其余三项是
28
+ * `evidence-gated-management.md` §5 的 mandatory bundle —— **denormalize** 进记录(而非读时回 report
29
+ * 解析),让受管记录自解释、可 grep、不依赖 report 文件仍在盘。旧记录(eval 写入前)无这三项,按
30
+ * optional 读;deriveManagedState 不依赖它们,故缺失不影响生命周期推导。 */
25
31
  export interface ManagedEvidenceRef {
26
32
  reportId: string;
27
33
  /** 该 report 测的是哪份内容(artifact contentHash)。读时只把与记录当前 contentHash 匹配的
28
34
  * evidence 算作当前有效证据——重装到新内容后旧证据保留供回滚,但不让新内容显得已测。 */
29
35
  contentHash: string;
30
36
  recordedAt: string;
37
+ /** §5 mandatory:report 时计算的 verdict 等级(PROGRESS / CAUTIOUS / REGRESS / NOISE /
38
+ * UNDERPOWERED / SOLO)。存字符串而非 import VerdictLevel —— 保 types 层为叶子、不依赖 eval-core。
39
+ * measurable 不看 verdict(任何评测都算"已测");verdict 是 promote 门控的事。 */
40
+ verdict?: string;
41
+ /** §5 mandatory:样本集覆盖。`count`=被测样本数,`hash`=report 的 sampleHashes 排序后摘要
42
+ * (同一样本集 ⇒ 同 hash),供 promote / list 不加载重 report 即可判覆盖与"同一用例集"。 */
43
+ sampleCoverage?: {
44
+ count: number;
45
+ hash: string;
46
+ };
47
+ /** §5 mandatory:可比性 marker。跨 report 比 verdict / Δ 前必须三者一致,否则不可比。 */
48
+ comparability?: {
49
+ cliVersion: string;
50
+ judgePromptHash?: string;
51
+ debiasMode?: Array<'length' | 'position'>;
52
+ };
31
53
  }
32
54
  export type ManagedDecisionKind = 'promote' | 'reject' | 'rollback';
33
55
  /** 一次人工管理决定。install 时为空,promote/reject/rollback 追加。 */
@@ -38,14 +60,17 @@ export interface ManagedDecision {
38
60
  reason?: string;
39
61
  }
40
62
  export interface ManagedArtifactSource {
41
- /** 源类型(限定判别字,非裸 kind)。`file`=本地路径;`git`=当前仓库的某个 ref。 */
63
+ /** 源类型(限定判别字,非裸 kind)。`file`=本地路径;`git`=当前仓库某 ref 或远端(带 url)。 */
42
64
  sourceKind: 'file' | 'git';
43
65
  /** 源身份,按 sourceKind 分义:
44
66
  * - file:本地重哈根(目录-skill 为根目录、文件-skill 为 .md),`hashArtifactSource(locator, isDirectorySkill)` 直接 round-trip;
45
- * - git:`git:<ref>:<name>`(非临时物化路径),drift 由 resolver 重物化重哈 —— mutable ref 给真实漂移、SHA 给不可变。 */
67
+ * - 本地 git:`git:<ref>:<name>`;远端 git:`git+<url>@<sha>:<name>`(均非临时物化路径,远端串仅作身份、不回喂 parseGitInput)。
68
+ * drift 由 resolver 重物化重哈 —— mutable ref 给真实漂移、SHA 给不可变。 */
46
69
  locator: string;
47
- /** git 来源的 ref(file 源无)。 */
70
+ /** git 来源的 ref(file 源无);远端为 fetch 后 pin 的实际 SHA。 */
48
71
  ref?: string;
72
+ /** 远端 git 的 URL(本地源 / 本地 git 无)。结构化存,供 drift 重取,不必从 locator 反 parse。 */
73
+ url?: string;
49
74
  /** 目录-skill(SKILL.md + assets)还是裸 .md 文件-skill。 */
50
75
  isDirectorySkill: boolean;
51
76
  }