niceeval 0.3.0 → 0.4.1

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 (102) hide show
  1. package/README.zh.md +11 -0
  2. package/docs/README.md +2 -1
  3. package/docs/adapters/collection.md +1 -1
  4. package/docs/adapters/contract.md +6 -5
  5. package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
  6. package/docs/assertions.md +0 -1
  7. package/docs/capabilities-by-construction.md +2 -2
  8. package/docs/cli.md +4 -2
  9. package/docs/concepts.md +2 -2
  10. package/docs/e2e-ci.md +135 -98
  11. package/docs/eval-authoring.md +3 -3
  12. package/docs/experiments.md +7 -4
  13. package/docs/observability.md +9 -5
  14. package/docs/origin-integration.md +18 -18
  15. package/docs/references.md +1 -1
  16. package/docs/reports.md +551 -0
  17. package/docs/results-format.md +3 -3
  18. package/docs/results-lib.md +191 -0
  19. package/docs/runner.md +1 -1
  20. package/docs/sandbox.md +1 -1
  21. package/docs/source-map.md +22 -2
  22. package/docs/tier-sync.md +74 -58
  23. package/docs/view.md +39 -5
  24. package/package.json +29 -3
  25. package/src/agents/ai-sdk.ts +3 -0
  26. package/src/agents/claude-code.ts +18 -1
  27. package/src/agents/codex.ts +3 -2
  28. package/src/agents/index.ts +16 -0
  29. package/src/agents/openai-compat.test.ts +56 -0
  30. package/src/agents/openai-compat.ts +151 -0
  31. package/src/agents/types.ts +3 -1
  32. package/src/cli.ts +4 -3
  33. package/src/context/context.test.ts +78 -1
  34. package/src/context/context.ts +19 -11
  35. package/src/context/session.ts +2 -0
  36. package/src/context/types.ts +11 -5
  37. package/src/i18n/en.ts +3 -4
  38. package/src/i18n/zh-CN.ts +2 -3
  39. package/src/o11y/cost.test.ts +40 -0
  40. package/src/o11y/cost.ts +27 -5
  41. package/src/o11y/derive.ts +2 -2
  42. package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
  43. package/src/o11y/otlp/mappers/claude-code.ts +24 -0
  44. package/src/o11y/otlp/mappers/index.ts +1 -0
  45. package/src/o11y/otlp/parse.test.ts +127 -0
  46. package/src/o11y/prices.json +176 -119
  47. package/src/o11y/types.ts +2 -1
  48. package/src/report/aggregate.ts +215 -0
  49. package/src/report/compute.ts +405 -0
  50. package/src/report/format.ts +47 -0
  51. package/src/report/index.ts +40 -0
  52. package/src/report/metrics.ts +96 -0
  53. package/src/report/react/CaseList.tsx +70 -0
  54. package/src/report/react/DeltaTable.tsx +79 -0
  55. package/src/report/react/MetricMatrix.tsx +75 -0
  56. package/src/report/react/MetricScatter.tsx +217 -0
  57. package/src/report/react/MetricTable.tsx +71 -0
  58. package/src/report/react/RunOverview.tsx +87 -0
  59. package/src/report/react/Scoreboard.tsx +87 -0
  60. package/src/report/react/cell.tsx +49 -0
  61. package/src/report/react/colors.ts +42 -0
  62. package/src/report/react/data.ts +17 -0
  63. package/src/report/react/fixtures.ts +236 -0
  64. package/src/report/react/format.ts +34 -0
  65. package/src/report/react/index.tsx +34 -0
  66. package/src/report/react/render.test.tsx +303 -0
  67. package/src/report/react/styles.css +302 -0
  68. package/src/report/report.test.ts +667 -0
  69. package/src/report/types.ts +210 -0
  70. package/src/results/copy.ts +200 -0
  71. package/src/results/format.ts +75 -0
  72. package/src/results/index.ts +27 -0
  73. package/src/results/open.ts +207 -0
  74. package/src/results/results.test.ts +359 -0
  75. package/src/results/select.ts +101 -0
  76. package/src/results/types.ts +98 -0
  77. package/src/runner/attempt.ts +3 -1
  78. package/src/runner/report.test.ts +111 -0
  79. package/src/runner/report.ts +52 -1
  80. package/src/runner/reporters/artifacts.ts +1 -1
  81. package/src/runner/reporters/braintrust.test.ts +106 -0
  82. package/src/runner/reporters/braintrust.ts +197 -0
  83. package/src/runner/reporters/index.ts +3 -1
  84. package/src/runner/run.ts +30 -19
  85. package/src/runner/types.ts +17 -0
  86. package/src/sandbox/types.ts +3 -3
  87. package/src/view/app/App.tsx +82 -17
  88. package/src/view/app/components/CostScoreChart.tsx +127 -0
  89. package/src/view/app/i18n.ts +10 -1
  90. package/src/view/app/lib/attempt-route.test.ts +89 -0
  91. package/src/view/app/lib/attempt-route.ts +52 -0
  92. package/src/view/app/lib/chart.ts +72 -0
  93. package/src/view/app/lib/outcome.ts +1 -1
  94. package/src/view/app/types.ts +4 -5
  95. package/src/view/client-dist/app.css +1 -1
  96. package/src/view/client-dist/app.js +18 -18
  97. package/src/view/index.ts +13 -19
  98. package/src/view/loader.test.ts +61 -0
  99. package/src/view/loader.ts +35 -12
  100. package/src/view/shared/types.ts +14 -1
  101. package/src/view/styles.css +43 -0
  102. package/src/view/template.html +0 -1
@@ -0,0 +1,191 @@
1
+ # Results Lib —— 实验结果数据的读写库(设计提案,未实现)
2
+
3
+ > 状态:读取面已首版落地——`src/results/` 的 openResults / 快照 / latestPerExperiment / 身份键去重 / copyRun(源码入口见 [Source Map](source-map.md#results-lib-与-reports));写入面 `createRunWriter` 与 view / `Artifacts()` reporter 的收编仍是提案。[Results Format](results-format.md) 是磁盘上的格式规范(已实现);本文提议把这份格式的**读与写**抽成一个专门的库 `niceeval/results`,做 runner、view、[Reports](reports.md) 和用户脚本的共同数据层。
4
+
5
+ 同一份磁盘格式,今天的写和读长在两个器官里。写在 `src/runner/reporters/artifacts.ts`:时间戳目录、attempt 路径清洗(evalId 保留 `/` 层级、agent/model 非 `[\w.@-]` 全换 `_`)、大字段拆工件、`artifactsDir` / `has*` 回填、空数据不落文件——全是它的私有知识。读长在 `src/view/index.ts`:`readSummary` 的版本判定、`loadSummaries` 的目录扫描、工件路径反拼。两边靠 `src/types.ts` 共享类型,但**布局知识各自实现了一遍**:格式演进要同步改两处,谁漏改谁坏。而用户侧根本没有读取 API,想编程消费只能第三次重写这些知识:
6
+
7
+ ```typescript
8
+ // 今天:想比「谁的代码短」,只能手工爬目录
9
+ const summary = JSON.parse(readFileSync(".niceeval/2026-07-02T.../summary.json", "utf-8"));
10
+ for (const r of summary.results) {
11
+ const diffPath = join(runDir, r.artifactsDir ?? "", "diff.json"); // 布局知识泄漏
12
+ if (!existsSync(diffPath)) continue; // 存在性自己判断
13
+ const diff = JSON.parse(readFileSync(diffPath, "utf-8")); // 类型自己想
14
+ }
15
+ ```
16
+
17
+ 抽成一个专门的库,理由就是 TypeScript 最擅长的那件事:**写和读是同一组 interface 的两半,住在同一个包里,writer 的参数类型 = reader 的返回类型**——「写出去的就是读得回的」由编译器背书,布局知识(路径、清洗、拆分、版本)全宇宙只有一份实现。
18
+
19
+ ## 库的边界
20
+
21
+ `niceeval/results` 拥有:
22
+
23
+ - **类型的家。** `RunSummary` / `EvalResult` / `StreamEvent` / `TraceSpan` / `O11ySummary` / `DiffData` 等结果类型和 `RESULTS_FORMAT` / `RESULTS_SCHEMA_VERSION` 常量搬进库;core 的 `src/types.ts` facade 反向 re-export,模块代码 `import type { … } from "../types.ts"` 的老习惯不破。
24
+ - **writer。** 目录创建、attempt 工件增量落盘、summary 收尾(见下)。
25
+ - **reader。** `openResults`:目录扫描、版本过滤、懒加载、快照切分、选择器(见下)。
26
+ - **身份。** attempt 身份键与去重规则——读写两侧对「同一个 attempt」的理解必须一致,所以住在这。
27
+
28
+ 它不拥有:「看法」(聚合、指标、组件在 [Reports](reports.md))、渲染(view)、执行(runner)。库位于依赖图最底层,不 import core 的任何其它模块;必要时可以原样提成独立 npm 包(给「只想解析或产出 niceeval 结果的工具」用),但先作为子路径导出,不预设。
29
+
30
+ 四个消费方:
31
+
32
+ | 消费方 | 用哪面 | 变化 |
33
+ |---|---|---|
34
+ | runner 的 `Artifacts()` reporter | 写 | 变薄壳:订阅 reporter 事件,转手调 writer,落盘行为不变 |
35
+ | `niceeval view` | 读 | `readSummary` / `loadSummaries` 改吃 reader,版本判定与 skipped 姿势顺带统一 |
36
+ | [Reports](reports.md) 的计算函数 | 读 | 第二档的全部数据入口 |
37
+ | 你的脚本 / 第三方工具 | 读、写、发布 | 读:自定义分析;写:把别家平台的结果转成 niceeval 格式,`niceeval view` 直接能看;发布:`copyRun` 瘦身快照进仓库 / CDN——兼容性都由库保证,不用抄格式文档 |
38
+
39
+ ## 写:`createRunWriter`
40
+
41
+ ```typescript
42
+ import { createRunWriter } from "niceeval/results";
43
+
44
+ const writer = await createRunWriter(".niceeval", {
45
+ producer: { name: "niceeval", version: "0.12.0" },
46
+ });
47
+ writer.dir; // .niceeval/2026-07-07T…Z/(时间戳目录,: 与 . 已替换)
48
+
49
+ await writer.writeAttempt(result); // 吃完整 EvalResult(events / diff 等大字段内联):
50
+ // 拆成 attempt 工件文件、算 artifactsDir(含路径清洗)、
51
+ // 回填 has* 引用,返回瘦身后的条目;空数据不落文件
52
+
53
+ await writer.finish(summary); // 写 summary.json,注入 format / schemaVersion / producer
54
+ ```
55
+
56
+ attempt 工件按完成增量写入(与今天的行为一致):长 run 中途失败,已完成的 attempt 工件仍留在盘上。`writeAttempt` 做的「大字段拆出去、引用填回来」正是 reader 懒加载的逆操作——两个方向的同一份知识,终于写在同一个文件里。
57
+
58
+ ## 复制与瘦身:`copyRun`
59
+
60
+ 发布场景的第三个原语:把选中的快照(或整个 run)按格式感知地复制到另一个目录——只带指定工件、只带选中的 attempt,布局知识不外泄:
61
+
62
+ ```typescript
63
+ import { copyRun } from "niceeval/results";
64
+
65
+ await copyRun(snapshots, "site/data/run", {
66
+ artifacts: ["sources", "events", "trace"], // diff 可达百 MB、o11y 查看器不读,发布时常见地不带
67
+ });
68
+ ```
69
+
70
+ 动机来自真实消费者:coding-agent-memory-evals 把最新 run 快照进仓库供静态托管,今天是 40 行手写脚本——按 `summary.json` 的 mtime 挑「最新」(口径还错了:该挑快照,不该挑 run),再按白名单拷贝工件文件(布局知识第三次泄漏)。`copyRun` 之后这段只剩上面三行,挑选交给 `latestPerExperiment`(见[静态导出场景](reports.md#dx-模拟))。复制忠实于源:不改内容、不消毒——发布消毒是自由文本的事,归 [Reports 的 `cases({ redact })`](reports.md#计算函数与数据契约)。
71
+
72
+ ## 读:`openResults`
73
+
74
+ 输入是 `.niceeval/` 目录(或单个 run 目录),输出是类型化句柄:
75
+
76
+ ```typescript
77
+ import { openResults } from "niceeval/results";
78
+
79
+ const results = await openResults(".niceeval");
80
+
81
+ results.runs; // RunHandle[]:一个落盘 run 目录一项,忠实反映磁盘,不合并
82
+ results.snapshots; // SnapshotHandle[]:experiment × run 的切片,选择与聚合的天然单元
83
+ results.skipped; // 读不了的 run:{ dir, reason, producerVersion? }[],不静默丢
84
+
85
+ const run = results.runs[0];
86
+ run.dir; // 绝对路径
87
+ run.summary; // RunSummary(与写入侧同一类型)
88
+ run.attempts; // AttemptHandle[]:summary.results[] 逐条包一层
89
+
90
+ const attempt = run.attempts[0];
91
+ attempt.result; // EvalResult 瘦身条目:判决、断言、用量、成本、experiment 元数据
92
+ attempt.ref; // { run, result }:证据引用(run 目录名 + results 下标),
93
+ // Reports 的 MetricCell.refs 与 view 深链 #/attempt/... 同一身份
94
+ await attempt.events(); // StreamEvent[] | null —— 重工件全部懒加载
95
+ await attempt.trace(); // TraceSpan[] | null
96
+ await attempt.o11y(); // O11ySummary | null
97
+ await attempt.diff(); // DiffData | null(可达百 MB,所以必须懒)
98
+ await attempt.sources(); // SourceArtifact[] | null
99
+ ```
100
+
101
+ 要点:
102
+
103
+ - **懒加载即存在性判断。** 工件缺失返回 `null`,不抛错。今天 summary 里只有 `hasEvents` / `hasTrace` / `hasSources`,连 `hasO11y` / `hasDiff` 标记都没有——这类不对称全被方法语义吸收,消费方不再碰路径。
104
+ - **版本过滤沿用格式规范。** 按 [Results Format 的版本规则](results-format.md#版本与升级设计)判定,不兼容的 run 进 `skipped` 并带 `producerVersion`(拼 `npx niceeval@<version> view` 提示的素材),与 [View 的报错与降级](view.md#报错与降级)同一姿势。
105
+ - **同一进程内按 handle 记忆化。** 两个都要读 diff 的消费方不会把「可达百 MB」的 `diff.json` 读两遍;扫全部历史仍然可能慢,但要慢得线性、可预期。
106
+ - **只读不写事实。** reader 的一切派生物删了随时可重算;唯一事实来源仍是磁盘上的 Results Format。
107
+
108
+ ## 快照:experiment × run,不是 run
109
+
110
+ 一次 CLI 调用写一个 run 目录,但一个 run 目录里可以装多个 experiment:`niceeval exp compare` 把整组对照跑进同一份 `summary.json`(runner 收的是 `agentRuns[]` 复数),顶层 `RunSummary.agent` 只是第一个配置的 agent(`src/runner/run.ts` 的 `summarize(allResults, firstAgent?.name …)`)。所以「每个 experiment 最新一次」没法用 run 粒度表达——周一跑了整组 compare,周二只重跑 `compare/bub-gpt-5.4`,bub 的最新快照在周二的 run 里,codex 的还在周一的 run 里。
111
+
112
+ reader 把这层身份显式化:**快照 = 一个 experiment 在一个 run 里的那部分 attempt**,与 [View · Compare 计划](view.md#compare-挑两次运行对比)的 `(experimentId, startedAt)` 同一口径:
113
+
114
+ ```typescript
115
+ interface SnapshotHandle {
116
+ experimentId: string; // 结果里缺 experimentId 时以 "<agent>/<model>" 合成键,并记入 warnings
117
+ run: RunHandle; // 所属物理 run
118
+ startedAt: string;
119
+ agent: string; // 本快照自己的 agent —— 不是 run 顶层那个「第一个配置」
120
+ model?: string;
121
+ attempts: AttemptHandle[];
122
+ evalIds: string[]; // 覆盖的 eval 集合,供选择器做残缺检测(见下)
123
+ }
124
+ ```
125
+
126
+ `results.runs` 忠实磁盘,`results.snapshots` 只切片、不合并、不去重;合并与聚合永远发生在消费方([Reports](reports.md) 的计算函数,或你自己的脚本),reader 不预设看法——这条教训来自 view 的 `aggregateRows` 把全部历史揉成一行的现状(见 [View · 已知差异](view.md#已知的文档-vs-实现差异))。
127
+
128
+ ## 选择快照:`latestPerExperiment`
129
+
130
+ 多数消费场景先回答「现在什么水平」,所以第一批只有一个选择器:
131
+
132
+ ```typescript
133
+ function latestPerExperiment(
134
+ snapshots: SnapshotHandle[],
135
+ opts?: { experiments?: string | string[] }, // experiment id 前缀过滤,同 CLI 语义
136
+ ): { snapshots: SnapshotHandle[]; warnings: string[] };
137
+ ```
138
+
139
+ 每个 experiment 取最新一次快照,最不误导。要累计历史就不调它;要更细的口径,普通 `.filter` 就够——选择器不是 DSL,只是最常用的那次筛选。
140
+
141
+ 但「最新」可能残缺:位置参数允许只重跑一道题(`niceeval exp midterm algebra/quadratic` 是正常的 debug 姿势),它产出的「最新快照」只有一道题,安静吞下的话下游报表就变成按一道题打分。所以**选择器同样要诚实**:把每个选中快照的 `evalIds` 与该 experiment 历史快照的并集对比,缩水就写进 `warnings`:
142
+
143
+ ```text
144
+ warning: snapshot "midterm/bub-gpt-5.4" @ 2026-07-05T… covers 1 of 50 evals seen in history.
145
+ Re-run `niceeval exp midterm/bub-gpt-5.4` for a full snapshot, or pick another via .filter().
146
+ ```
147
+
148
+ `warnings` 是普通字符串数组,渲染与否在消费方,但缺口永远被算出来,不静默。
149
+
150
+ ## 身份键与去重
151
+
152
+ `--resume` 会把上一轮已通过的结果**原样合入**新 run 的 summary(`RunOptions.priorResults`):这让续跑出来的最新快照天然完整(正好配合 `latestPerExperiment`),代价是同一个 attempt 存在于多份落盘,而 `EvalResult` 今天没有任何「合入」标记。
153
+
154
+ reader 忠实反映这份重复,不擅自去重;**跨快照聚合前按身份键去重是消费方的义务**:
155
+
156
+ - 身份键:`(experimentId, evalId, attempt, startedAt)`,重复时保留最新 run 里的那份;
157
+ - `startedAt` 缺失时宁可不去重也不误删,并记入 warnings;
158
+ - [Reports 的计算函数](reports.md#计算函数与数据契约)内置这条;自己写脚本跨快照累计时,要么复用计算函数,要么自己按键去重。
159
+
160
+ 更根治的做法是 writer 给合入的结果打标——读写同库之后,这类格式演进只改一处实现,再同步 [Results Format](results-format.md) 规范即可;是否值得为它递增 `schemaVersion`,留给格式规范那边定。
161
+
162
+ ## 直接吃读取面:一个真实脚本
163
+
164
+ 折叠类的看法(表格、矩阵、成绩单、散点)去用 [Reports](reports.md) 的计算函数;直接吃 reader 服务的是连算法都自定义的场景,比如「每个 agent 的 shell 命令分布直方图」——那是分布,不是折叠:
165
+
166
+ ```typescript
167
+ import { openResults } from "niceeval/results";
168
+
169
+ const results = await openResults(".niceeval");
170
+ const points = [];
171
+ for (const snap of results.snapshots) {
172
+ for (const attempt of snap.attempts) {
173
+ const o11y = await attempt.o11y();
174
+ points.push({
175
+ agent: snap.agent,
176
+ eval: attempt.result.id,
177
+ passed: attempt.result.outcome === "passed",
178
+ shellCommands: o11y?.shellCommands.length ?? 0,
179
+ });
180
+ }
181
+ }
182
+ ```
183
+
184
+ 即使在这条最深的路径上,用户也**不碰磁盘布局**——`artifactsDir` 拼接、存在性判断、版本过滤、快照切分都被库消化了。Results Format 若演进,全宇宙只有这一个库要改。
185
+
186
+ ## 相关阅读
187
+
188
+ - [Results Format](results-format.md) —— 磁盘格式规范;本库是它唯一的官方读写实现,一格式一库,成对演进。
189
+ - [Reports](reports.md) —— 建立在本库读取面之上的积木:指标、计算函数、React 组件。
190
+ - [View](view.md) —— 内置前端;`readSummary` / `loadSummaries` / skipped 处理是本库 reader 要收编的现状。
191
+ - [Experiments](experiments.md) —— experimentId 与可对比组从哪来。
package/docs/runner.md CHANGED
@@ -68,7 +68,7 @@ fixtures/button codex pass@5 = 3/5 (60%) mean 41s · 72k tok · $0.3
68
68
 
69
69
  ## 环境预置不进运行器
70
70
 
71
- niceeval **没有框架级生命周期钩子**——运行器只固定"发现 → 调度 → 沙箱起停 / git 基线 / 采 diff → 评分 → 报告"这条主轴,不替用户跑任何环境 `setup` / `teardown`。要在跑 agent 前准备环境,分摊到三处已有职责:连 agent / 装 CLI 走 [`SandboxAgent.setup`](adapters/contract.md#agent-契约),这条 eval 的沙箱预置写在 `test(t)` 里,整轮共享的外部服务(mock API、DB)用外部编排(`docker compose` / CI 脚本)起停、经 env 传入。完整说明见 [环境预置放哪](sandbox.md#环境预置放哪)。
71
+ niceeval **没有 run / experiment 级生命周期钩子**——运行器只固定"发现 → 调度 → 沙箱起停 / git 基线 / 采 diff → 评分 → 报告"这条主轴,不替用户在整轮实验前后跑任意 `setup` / `teardown`。要在跑 agent 前准备环境,分摊到三处已有职责:这条 eval 的沙箱预置走 `EvalDef.setup` 或 `test(t)`,连 agent / 装 CLI 走 [`SandboxAgent.setup`](adapters/contract.md#agent-契约),整轮共享的外部服务(mock API、DB)用外部编排(`docker compose` / CI 脚本)起停、经 env 传入。完整说明见 [环境预置放哪](sandbox.md#环境预置放哪)。
72
72
 
73
73
  **下游分析**(二次评分、自定义指标)走 [reporter](observability.md#reporters),不另设运行钩子——这是从 agent-eval 的 `onRunComplete` 收敛过来的(见 [Experiments 砍字段](experiments.md#从-agent-eval-砍掉了什么以及为什么))。
74
74
 
package/docs/sandbox.md CHANGED
@@ -57,7 +57,7 @@ interface Sandbox {
57
57
 
58
58
  契约一句话:**API 里任何沙箱侧相对路径,一律解析到 `workdir`;省略的 `targetDir` / `cwd` 默认就是 `workdir`;绝对路径原样使用。** 本地侧(宿主机)的相对路径则解析到 eval 定义文件所在目录。两侧各只有一个锚点,学一次就够。
59
59
 
60
- 为什么 workdir 是唯一正确的默认值:整条流水线都锚定在它上面——git 基线打在那里、agent 的 cwd 在那里、跑完 `git diff HEAD` 采改动在那里、`t.fileChanged(...)` 的路径也是对着那里解析的。把起始文件传到任何**别的**目录,agent 看不见它,diff 也采不到它,整条 eval 静默失效。所以对上传起始 workspace 这个最高频调用来说,workdir 不是"常见选择",是唯一能让系统其余部分正常工作的选择——一个参数如果 99% 的调用只有一个正确值,而调用者又不掌握这个值(它随后端变),强制填写就不是"显式更安全",是逼人抄错答案。
60
+ 为什么 workdir 是唯一正确的默认值:整条流水线都锚定在它上面——git 基线打在那里、agent 的 cwd 在那里、跑完 `git diff HEAD` 采改动在那里、`t.sandbox.fileChanged(...)` 的路径也是对着那里解析的。把起始文件传到任何**别的**目录,agent 看不见它,diff 也采不到它,整条 eval 静默失效。所以对上传起始 workspace 这个最高频调用来说,workdir 不是"常见选择",是唯一能让系统其余部分正常工作的选择——一个参数如果 99% 的调用只有一个正确值,而调用者又不掌握这个值(它随后端变),强制填写就不是"显式更安全",是逼人抄错答案。
61
61
 
62
62
  ### 用户会怎么写:before / after
63
63
 
@@ -23,7 +23,7 @@
23
23
  | 能力调用守卫(缺声明的动作第一次调用即报清晰错误;conversation gate 第二轮起) | `src/context/context.ts`(`capabilityGuard`) |
24
24
  | 逐 API 适配义务(send / newSession / respond 的运行器侧翻译) | `src/context/session.ts`(`SessionManager` / `RunSession`)、`src/context/context.ts` |
25
25
  | `defineSandboxAgent` / `defineAgent`(`kind: "sandbox" | "remote"`,无能力位字段) | `src/define.ts` |
26
- | `shared` 工具袋(ensureInstalled / captureLatestJsonl(可按 sessionId 精确定位)/ writeFile / extractJsonlFromStdout / codexThreadId / firstJsonField / shellSingleQuote / diagnoseFailure / parseCodex·parseClaudeCode·parseBub) | `src/agents/shared.ts` |
26
+ | `shared` 工具袋(ensureInstalled / captureLatestJsonl(可按 sessionId 精确定位)/ writeFile / extractJsonlFromStdout / codexThreadId / firstJsonField / shellQuote / diagnoseFailure / parseCodex·parseClaudeCode·parseBub) | `src/agents/shared.ts` |
27
27
  | 采集矩阵(collection.md:每 agent 的通道 / 字段来源) | `src/agents/{claude-code,codex,bub}.ts`(采集)+ `src/o11y/parsers/*.ts`(字段提取) |
28
28
  | `fromAiSdk`(AI SDK 结果 → 标准事件流,v4/v5/v7 字段漂移兜底;v7 tool approval → `input.requested` + `status: "waiting"`) | `src/agents/ai-sdk.ts`(+ 同目录 `.test.ts`) |
29
29
  | 内置 adapter(claude-code / codex / bub) | **由被测项目自带**(`agents/*.ts`),niceeval 提供 `shared` + 解析器 |
@@ -90,14 +90,34 @@
90
90
  | 有界并发调度 + 早停 + budget 在飞预扣 | `src/runner/run.ts` |
91
91
  | 单 attempt 生命周期(沙箱 / OTLP 接收器 Scope、超时硬边界、沙箱编排固定段) | `src/runner/attempt.ts` |
92
92
  | 指纹缓存((eval 源码 + 运行配置) 哈希,跨 run 结果携入) | `src/runner/fingerprint.ts` |
93
- | reporter 编排 + 运行级汇总 | `src/runner/report.ts` |
93
+ | reporter 编排 + 运行级汇总 + eval 级 reporter 作用域(scopeReporter / filterSummary) | `src/runner/report.ts` |
94
94
  | remote 占位 Sandbox / eval 级本地路径视图(Proxy) | `src/runner/remote-sandbox.ts` |
95
95
  | 报告器(Console / Json / JUnit / Live / 符号表) | `src/runner/reporters/{console,json,live,table,shared,index}.ts` |
96
+ | Braintrust 上报(运行 → experiment,attempt → 一行) | `src/runner/reporters/braintrust.ts` |
96
97
  | eval 级折叠 / 计票口径(CLI 表格与 view 共用) | `src/shared/outcome.ts` |
97
98
  | 本地结果保存格式(`.niceeval/<run>/summary.json` + attempt 级 JSON 工件) | `src/runner/reporters/artifacts.ts`、`src/runner/types.ts`(`RunSummary` / `EvalResult`) |
98
99
  | CLI(exp / list / view / clean / init,--help,parseArgs 表驱动,.env 加载,NICEEVAL_* 环境变量层) | `src/cli.ts` |
99
100
  | 数据集加载器(loadJson / loadYaml) | `src/loaders/index.ts` |
100
101
 
102
+ ## Results Lib 与 Reports
103
+
104
+ 设计文档:[results-lib.md](results-lib.md) / [reports.md](reports.md) / [view.md](view.md) 合流一节。两个提案的首版实现:
105
+
106
+ | 行为 | 文件 |
107
+ |---|---|
108
+ | `openResults`:目录扫描 / 版本分流(skipped)/ 懒加载句柄 / 快照切片 | `src/results/open.ts` |
109
+ | 布局与版本知识(attempt 目录规则、summary 分类;与 writer 的 `artifacts.ts` 同规则) | `src/results/format.ts` |
110
+ | `latestPerExperiment`(残缺警告)/ `dedupeAttempts`(身份键去重) | `src/results/select.ts` |
111
+ | `copyRun`(发布原语:按工件种类瘦身复制 + summary 重建) | `src/results/copy.ts` |
112
+ | 句柄契约(RunHandle / AttemptHandle 含 `.ref` / SnapshotHandle / AttemptRef) | `src/results/types.ts` |
113
+ | `defineMetric` 与内置指标(outcome 逐项表态) | `src/report/metrics.ts` |
114
+ | 两级聚合引擎 / 维度 / MetricCell 计算 | `src/report/aggregate.ts` |
115
+ | 七个计算函数(table / matrix / scoreboard / scatter / overview / delta / cases) | `src/report/compute.ts` |
116
+ | 组件数据契约(TableData … CaseListData;组件 props 的家) | `src/report/types.ts` |
117
+ | 七个 React 组件 + 稳定散列配色 + styles.css | `src/report/react/`(入口 `index.tsx`;演示 `scripts/report-react-demo.tsx`) |
118
+ | view attempt 深链(`#/attempt/<run>/<result>`,路由参数即 AttemptRef) | `src/view/app/lib/attempt-route.ts`、`src/view/app/App.tsx`、`src/view/loader.ts`(`withViewRefs` 注入) |
119
+ | **未落地** | 写入面 `createRunWriter`、view / `Artifacts()` reporter 改吃本库、memory-evals 静态导出流水线(reports.md 场景三) |
120
+
101
121
  ## 与设计文档的已知差异(实现取舍)
102
122
 
103
123
  - **judge 走 OpenAI 兼容 `/chat/completions`**,base/key 解析顺序:`judge.baseUrl/apiKeyEnv` → `NICEEVAL_JUDGE_BASE`/`CODEX_BASE_URL` → OpenAI 官方。这样在只有 OpenAI 兼容代理(无 Anthropic key)的环境里 judge 自动复用代理。model 解析:eval/config 的 `judge.model` → `NICEEVAL_JUDGE_MODEL`;**没有内置默认模型**,解析不到而用到 judge 断言时报清晰错误。
package/docs/tier-sync.md CHANGED
@@ -1,24 +1,25 @@
1
- # Tier Sync:examples 的 origin → tier1 → tier2 同步维护方案
1
+ # Tier Sync:examples 的 origin → tier1 → tier2 → tier3 同步维护方案
2
2
 
3
- > **状态:已实现。** `scripts/sync-tiers.mjs`、`pnpm tiers:sync`、`pnpm tiers:check` 已落地,CI 在 `pnpm run typecheck` 之后跑 `tiers:check`。`examples/zh/.tier-sync.json` 已为现有五对目录初始化 baseTree,首次运行 `tiers:sync` 确认是无操作。
3
+ > **状态:已实现。** `scripts/sync-tiers.mjs`、`pnpm tiers:sync`、`pnpm tiers:check` 已落地,CI 在 `pnpm run typecheck` 之后跑 `tiers:check`。`examples/zh/.tier-sync.json` 登记全部目录对;链条是 origin tier1 → tier2 → tier3(哪个应用有哪几层见 [examples/zh/README](../examples/zh/origin/README.md) 与状态文件本身)。
4
4
 
5
5
  ## 问题
6
6
 
7
- `examples/zh/` 里同一个应用存两份:`origin/<name>` 是接入 niceeval 之前的原始应用,`tier1/<name>` 是它的完整副本加上接入产物。存两份是刻意的——`gen:diff-code` diff 这两个目录生成 before/after 文档页,"应用侧零改动"是产品卖点,所以副本文件必须和 origin 逐字节相同(只有 `package.json` / `pnpm-workspace.yaml` / `tsconfig.json` 三个脚手架文件例外)。以 codex-sdk 为例,tier1 21 个跟踪文件里 9 个是 origin 的逐字节副本,4 个是允许有差异的脚手架/机器产物(`package.json`、`tsconfig.json`、`pnpm-workspace.yaml`、`pnpm-lock.yaml`),其余 8 个(`agents/`、`evals/`、`experiments/`、`niceeval.config.ts`、`README.md`)是 tier 私有新增。
7
+ `examples/zh/` 里同一个应用按接入深度存多份:`origin/<name>` 是接入 niceeval 之前的原始应用,`tier1/<name>` 是它的完整副本加上无侵入接入产物,`tier2/<name>` 在 tier1 之上把 OTel 观测接进来,`tier3/<name>` 再往上做侵入改造暴露 experiment flags。存多份是刻意的——`gen:diff-code` diff 相邻两层生成 before/after 文档页,"每一档只加一层 delta"是产品叙事的骨架,所以 tier1 里被复制的文件必须和 origin 逐字节相同(只有 `package.json` / `pnpm-workspace.yaml` / `tsconfig.json` 三个脚手架文件加 `.env.example`——tier 侧要补 judge 独立凭证等 eval 变量——例外)。以 codex-sdk 为例,tier1 的跟踪文件里大半是 origin 的逐字节副本,其余是 tier 私有新增(`agents/`、`evals/`、`experiments/`、`niceeval.config.ts`、`README.md`)
8
8
 
9
- 麻烦在于 origin 改得很勤(修 bug、调演示场景、升依赖),而副本不会自己跟上。今天给 `origin/codex-sdk/src/backend/server.ts` 修个 bug,就得记得 cp 一份到 tier1 的同名路径;要是动的是 `package.json`,连 cp 都不行——tier1 那份多着 niceeval 的集成行,只能打开手工重放同一行,再进 tier1 跑一遍 `pnpm install` 让 lockfile 跟上。这套动作对每个受影响的示例(现在 5 个)各来一遍,tier2 落地后再乘一层,而且全程只靠改动者的记忆串着。
9
+ 麻烦在于 origin 改得很勤(修 bug、调演示场景、升依赖),而副本不会自己跟上。今天给 `origin/codex-sdk/src/backend/server.ts` 修个 bug,就得记得 cp 一份到 tier1 的同名路径——然后 tier2、tier3 还各有一份;要是动的是 `package.json`,连 cp 都不行——tier 那份多着 niceeval 的集成行,只能打开手工重放同一行,再进各层跑一遍 `pnpm install` 让 lockfile 跟上。这套动作对每个受影响的示例各来一遍,层数越多乘数越大,而且全程只靠改动者的记忆串着。
10
10
 
11
- 忘了会怎样?什么都不会发生——这正是最糟的部分。没有检查、没有报警,review 时"改了 origin 而 tier1 没动"的 diff 看起来完全正常。漂移安静地躺着,直到某天重新生成 diff 文档页,陈旧差异被当成"接入 niceeval 需要改的代码"展示出来,把"零改动"卖点砸掉;或者用户跑 tier1 示例,踩到 origin 早就修掉的 bug。到那时离引入漂移的那次改动已经很远,没人记得该同步什么。
11
+ 忘了会怎样?什么都不会发生——这正是最糟的部分。没有检查、没有报警,review 时"改了 origin 而 tier1 没动"的 diff 看起来完全正常。漂移安静地躺着,直到某天重新生成 diff 文档页,陈旧差异被当成"接入 niceeval 需要改的代码"展示出来,把"零改动"卖点砸掉;或者用户跑 tier 示例,踩到 origin 早就修掉的 bug。到那时离引入漂移的那次改动已经很远,没人记得该同步什么。
12
12
 
13
- 值得说清的一点:这不是"两边各改各的"的双向合并难题。实测五个示例,tier1 对共享文件的改动只有脚手架那三个文件各 2~4 行,应用源码两边完全一致;其余差异全是 tier 私有的新增文件(`agents/`、`evals/`……)和机器产物 lockfile。tier1 本质上就是"origin + 一小层接入 delta",我们缺的操作就是 `tier1 rebase origin`:origin 动了,一条命令把 delta 重放到新 origin 之上。只是原生 `git rebase` 作用于分支,而这里是同一棵树里的两个目录,原生命令用不上——本方案就是给目录对补上这个动词。
13
+ 值得说清的一点:这条链上有两种性质不同的目录对。**origin → tier1 是 "副本 + 纯新增"**:tier1 对共享文件的改动只有脚手架那几个文件各 2~4 行,应用源码两边完全一致,其余差异全是 tier 私有的新增文件——它本质上就是"origin + 一小层接入 delta"。**tier1 → tier2、tier2 → tier3 是 "副本 + 修改"**:tier2 会改 tier1 带来的 eval 侧文件(`niceeval.config.ts` telemetry、adapter spanMapper),tier3 更是按定义要改应用的 `src/`(把内部可变点暴露成 flags)。两种目录对需要的都是同一个缺失的动词——`下游 rebase 上游`:上游动了,一条命令把本层的 delta 重放到新上游之上。只是原生 `git rebase` 作用于分支,而这里是同一棵树里的目录,原生命令用不上——本方案就是给目录对补上这个动词,顺带给"纯新增"那种目录对加上逐字节铁律的 CI 检查。
14
14
 
15
15
  ## 方案一句话
16
16
 
17
17
  **用 rebase 的底层机制直接作用于目录树。** git 的 rebase/cherry-pick 每重放一步,内部就是一次"以共同祖先为 base 的三方合并";这个机制不要求输入是分支,`git merge-tree --write-tree --merge-base=<base>`(git ≥ 2.38)可以对任意三棵 tree 使用。于是:
18
18
 
19
19
  - 每对目录记录一个"上次同步时上游目录的 tree hash"作为 base(相当于 rebase 里的分叉点);
20
- - `tiers:sync` = 一条 `git merge-tree --write-tree --merge-base=<base> <tier树> <上游树>`,把 origin 自分叉点以来的全部变更重放进 tier,同时保住 tier 的接入 delta——**这就是 `tier1 rebase origin`**,冲突体验也和 rebase 一致(就地留标记,人解完继续);
21
- - CI 用 tree hash 比对做秒级防漂移检查。
20
+ - `tiers:sync` = 一条 `git merge-tree --write-tree --merge-base=<base> <tier树> <上游树>`,把上游自分叉点以来的全部变更重放进 tier,同时保住 tier delta——**这就是 `tier rebase 上游`**,冲突体验也和 rebase 一致(就地留标记,人解完继续);
21
+ - 链式同步一条命令跑完:上一对的合并结果树直接作为下一对的上游输入,中途不要求提交;
22
+ - CI 用 tree hash 比对做秒级防漂移检查,对 origin → tier1 这种"纯新增"对额外做逐字节 verbatim 校验。
22
23
 
23
24
  **不维护 overlay 清单**("哪些文件是 tier 私有的"由三方合并自动推导),**patch 只作为同步后自动导出的阅读产物,不作为事实源**。这也是 Google copybara / `git subtree` 解决的"目录级 vendoring + 上游追踪"问题的仓库内轻量版:合并机制 100% 是 git 自己的,脚本只做粘合。
24
25
 
@@ -28,8 +29,8 @@
28
29
 
29
30
  既然想要的是 rebase,最直觉的做法是让它真的可 rebase:每个示例的 origin 放一条独立分支,tier1 是它之上的接入提交,同步 = 原生 `git rebase`。否决理由:
30
31
 
31
- - **示例必须同时存在于 main 的工作树里。** 文档链接指向真实目录、用户 clone 后并排浏览 origin 与 tier1、`gen:diff-code` 直接 diff 两个目录——这些都要求两份代码在同一棵树上共存。分支化之后,还得把每条分支的内容物化回 main 的目录(否则以上全断),于是"分支"与"物化目录"变成双份记账,比现在更糟;
32
- - **分支数量是 示例数 × tier 层数,** 五个示例两层就是 10+ 条长期分支,rebase 后全部需要 force-push,历史被反复改写,协作成本失控;
32
+ - **示例必须同时存在于 main 的工作树里。** 文档链接指向真实目录、用户 clone 后并排浏览各层、`gen:diff-code` 直接 diff 两个目录——这些都要求全部代码在同一棵树上共存。分支化之后,还得把每条分支的内容物化回 main 的目录(否则以上全断),于是"分支"与"物化目录"变成双份记账,比现在更糟;
33
+ - **分支数量是 示例数 × tier 层数,** 五个示例三四层就是 15+ 条长期分支,rebase 后全部需要 force-push,历史被反复改写,协作成本失控;
33
34
  - `git subtree split` 能把目录合成出伪分支再 rebase 回填,但那是三步咒语级的操作,比直接对目录树做三方合并绕远得多。
34
35
 
35
36
  结论:要的是 rebase 的**机制**,不是 rebase 的**命令**。机制(同 base 三方合并)可以脱离分支直接用在目录树上,见"方案一句话"。
@@ -41,7 +42,7 @@
41
42
  - **双事实源,DX 崩坏。** 改接入代码(evals、agents、脚手架几行)时,要么手写 patch 文件——没人受得了;要么改物化目录再重新导出——目录和 patch 变成两份事实源,永远在打架。git history 也不可读了:改一行 eval,commit diff 是"diff 的 diff"。
42
43
  - **示例必须是可运行、可浏览的真实目录。** 仓库规则要求文档链接指向真实目录,用户会 clone 后直接 `cd examples/zh/tier1/codex-sdk` 跑起来、在 GitHub 上直接读代码(有语法高亮和跳转,patch 文件没有)。物化产物要么提交(回到原点),要么 gitignore(文档链接与 GitHub 浏览全断)。
43
44
  - **lockfile 无法用 patch 维护。** `pnpm-lock.yaml` 的内容随依赖版本剧烈漂移,patch 很快 apply 失败;它只能由 `pnpm install` 重新生成。
44
- - **堆叠 patch 是出了名的维护地狱。** origin 改一行,可能要连修 tier1、tier2 两层 patch 的 fuzz/reject。现在的痛是"复制一遍",换成堆叠 patch 后痛变成"手工解 reject",更糟。
45
+ - **堆叠 patch 是出了名的维护地狱。** origin 改一行,可能要连修 tier1、tier2、tier3 三层 patch 的 fuzz/reject。现在的痛是"复制一遍",换成堆叠 patch 后痛变成"手工解 reject",更糟。
45
46
 
46
47
  **"方便阅读"这个需求单独满足,不必绑架存储格式**:`gen:diff-code` 已经从两个物化目录生成 before/after 阅读页;本方案再让 `tiers:sync` 顺手导出一份 `<name>.patch` 纯阅读件(见下文)。patch 当**输出**,不当**输入**。
47
48
 
@@ -51,17 +52,24 @@
51
52
 
52
53
  - **清单要人工维护,且各示例不同**(langgraph 的 `package.json` 是 tier 私有,其它示例的是"共享但微改"),清单本身会漂移;
53
54
  - **allowDiff 即检查盲区**:`package.json` 一旦列入 allowDiff,origin 给应用加依赖时,检查发现不了 tier1 没跟上。
55
+ - 对 tier2/tier3 这种"副本 + 修改"的目录对,清单会膨胀成"每个被 overlay 的文件一条",彻底退化成手工记账。
54
56
 
55
- 三方合并没有这两个问题:私有文件自动推导,`package.json` 走真合并——origin 的新依赖能不能干净合入 tier1 的 `package.json`,取决于新依赖是否落在 tier 那 +2 行改动的同一位置(两者都紧挨着追加时会冲突,见下文"已知取舍");但即便冲突,也是显式报错,而不是像 allowDiff 清单那样检查不到。
57
+ 三方合并没有这些问题:私有文件自动推导,`package.json` 走真合并——origin 的新依赖能不能干净合入 tier1 的 `package.json`,取决于新依赖是否落在 tier 那 +2 行改动的同一位置(两者都紧挨着追加时会冲突,见下文"已知取舍");但即便冲突,也是显式报错,而不是像 allowDiff 清单那样检查不到。
56
58
 
57
59
  ### 采纳三方合并的正面理由
58
60
 
59
- - **改 origin tier 自动快进**:tier 从不改 `src/`,所以 `src/` 的所有变更都是无冲突快进复制,覆盖日常 95% 的场景;
60
- - **逐字节不变的铁律从"靠自觉"变成"CI 保证"**:检查模式发现漂移直接红;
61
- - **`gen:diff-code`、"接入只要 10-50 行"的验收方式完全不变**:两边仍是完整目录,`diff -r` 照旧;
62
- - **tier2 免费获得同样机制**:上游指向 tier1 即可,形成 origin → tier1 → tier2 的链。
61
+ - **改 origin 后各层自动快进**:tier1/tier2 从不改应用 `src/`,所以 `src/` 的变更一路无冲突快进复制;tier3 改过的 `src/` 文件走真三方合并,只有上游改到 tier3 动过的同一区域才需要人工裁决——这正是三方合并存在的意义,覆盖日常绝大多数场景;
62
+ - **"origin → tier1 逐字节不变"的铁律从"靠自觉"变成"CI 保证"**:check 模式对 verbatim 契约的目录对做逐字节比对,漂移直接红(见"检查模式");
63
+ - **`gen:diff-code`、"接入只要 10-50 行"的验收方式完全不变**:相邻两层仍是完整目录,`diff -r` 照旧;
64
+ - **加一层免费获得同样机制**:上游指向上一层即可,形成 origin → tier1 → tier2 → tier3 的链,一条 `tiers:sync` 从头穿到尾。
63
65
 
64
- 已知取舍:三方合并按行进行,两边在文件同一位置追加会冲突。这不是理论风险——沙盘验证时就复现了一例:origin 给 `dependencies` 加 `zod`、tier 在同一位置有 `niceeval`,merge-tree 就地留下标记要求人工裁决,体验与 rebase 冲突完全一致。按现有 diff 形状(脚手架文件各自只动 2~4 行)冲突频率低,且永远是**显式报错**(留标记 + check 拦截)而非静默错合。文件重命名被视为"删除 + 新增",无 rename 检测;origin 重命名文件时 tier 侧若改过该文件会报一次冲突,人工确认即可。
66
+ 已知取舍:
67
+
68
+ - 三方合并按行进行,两边在文件同一位置追加会冲突。这不是理论风险——沙盘验证时就复现了一例:origin 给 `dependencies` 加 `zod`、tier 在同一位置有 `niceeval`,merge-tree 就地留下标记要求人工裁决,体验与 rebase 冲突一致。按现有 diff 形状冲突频率低,且永远是**显式报错**(留标记 + check 拦截)而非静默错合。
69
+ - **解决冲突后不能简单重跑合并**:base 没动的情况下,同 base 三方合并会把同一处冲突原样再报一遍(这是合并语义的固有行为,git rebase 靠 `--continue` 记录裁决绕开它)。所以脚本在报冲突时把"这次要合到哪"记进状态文件的 `pending`,人解完标记、提交后重跑 `tiers:sync` 走的是**收尾**而不是重新合并(见"同步算法")。
70
+ - 文件重命名被视为"删除 + 新增",无 rename 检测;origin 重命名文件时 tier 侧若改过该文件会报一次冲突,人工确认即可。
71
+ - **lockfile 不参与合并**:`pnpm-lock.yaml` 完全由各层自己的 `pnpm install` 生成。上游只动 lockfile 而不动 `package.json` 的变更(比如区间内 `pnpm up`)不会传播到下游——需要时在下游手动 `pnpm install` 即可,这类变更不影响任何展示或断言。
72
+ - **verbatim 检查只覆盖 origin → tier1**。tier2/tier3 是"副本 + 修改"(overlay 契约),没有"必须逐字节一致"可言,check 对它们只能保证"上游动了必有同步动作";有人直接改 tier2 里本应跟随 tier1 的文件并提交,机器发现不了,要靠 review 时看 `diffs/<层级>-<name>.patch` 的形状。
65
73
 
66
74
  ## 如何实现
67
75
 
@@ -72,27 +80,29 @@
72
80
  ```json
73
81
  {
74
82
  "pairs": [
75
- { "from": "examples/zh/origin/codex-sdk", "to": "examples/zh/tier1/codex-sdk", "baseTree": "a1b2c3..." },
76
- { "from": "examples/zh/origin/langgraph", "to": "examples/zh/tier1/langgraph", "baseTree": "d4e5f6..." }
83
+ { "from": "examples/zh/origin/codex-sdk", "to": "examples/zh/tier1/codex-sdk", "contract": "verbatim", "baseTree": "a1b2c3..." },
84
+ { "from": "examples/zh/tier1/codex-sdk", "to": "examples/zh/tier2/codex-sdk", "contract": "overlay", "baseTree": "d4e5f6..." },
85
+ { "from": "examples/zh/tier2/codex-sdk", "to": "examples/zh/tier3/codex-sdk", "contract": "overlay", "baseTree": "0718ab..." }
77
86
  ]
78
87
  }
79
88
  ```
80
89
 
81
- `baseTree` 是上次同步时上游目录的 git tree hash(`git rev-parse HEAD:examples/zh/origin/codex-sdk`)。base 的**内容**不需要另存——git 对象库天然保管,`git cat-file blob <baseTree>:<相对路径>` 随取随用。配对关系也可由约定推导(同名目录出现在相邻两层即为一对),状态文件里只需 baseTree;先按显式 pairs 实现,更直白。
90
+ - `baseTree` 是上次同步时上游目录**剥掉 `pnpm-lock.yaml` 之后**的 git tree hash(lockfile 不参与合并与比对,理由见"已知取舍";剥法是 `git ls-tree` 过滤后 `git mktree`,几毫秒)。base 的**内容**不需要另存——git 对象库天然保管。
91
+ - `contract` 声明这对目录的性质:`verbatim`(origin → tier1,"副本 + 纯新增",check 会做逐字节校验)或 `overlay`(tier1 → tier2、tier2 → tier3,"副本 + 修改",不做逐字节校验)。
92
+ - 报冲突时脚本会往该 pair 写一个 `pending` 字段(要合到的上游 tree + 是否需要重装依赖),收尾后自动删掉;它是同步中间态,不要手工编辑。
93
+ - pairs 的书写顺序不重要——脚本按 from/to 关系做拓扑排序;成环报错。
82
94
 
83
95
  ### 同步算法(`pnpm tiers:sync [name]`)
84
96
 
85
- 前置条件:**上游与 tier 两侧目录都必须无未提交改动**(`git status --porcelain <from> <to>` 为空)——合并的三个输入都取自提交过的 tree,同步才可复现、可回溯。工作流固定为:改 origin → 提交 → sync → review → 提交 tier。
97
+ 前置条件:**上游与 tier 两侧目录都必须无未提交改动**(lockfile 除外,它不参与比对)——合并的三个输入都取自提交过的 tree,同步才可复现、可回溯。工作流固定为:改 origin → 提交 → sync(自动穿透整条链)→ review → 一起提交。带 `name` 参数(目录 basename,如 `codex-sdk`)时只同步该应用的链。
86
98
 
87
- 对每一对 (from, to),核心是一条 git 命令:
99
+ 对每一对 (from, to),核心是一条 git 命令(三棵输入树都已剥 lockfile):
88
100
 
89
101
  ```sh
90
- git merge-tree --write-tree --merge-base=<baseTree> \
91
- $(git rev-parse HEAD:examples/zh/tier1/codex-sdk) \
92
- $(git rev-parse HEAD:examples/zh/origin/codex-sdk)
102
+ git merge-tree --write-tree --merge-base=<baseTree> <tier树> <上游树>
93
103
  ```
94
104
 
95
- 输出第一行是合并后的 tree hash(退出码非零表示有冲突,后续行给出冲突文件清单;冲突文件在结果 tree 里已含 `<<<<<<<` 标记)。脚本把结果 tree 检出到 tier 目录(`git archive <tree> | tar -x -C <to>`)即完成同步。该命令已在 git 2.50 上沙盘验证:源码修改与新增文件干净重放、tier 私有文件原样保留、同点追加正确报冲突。逐文件语义与 git merge/rebase 完全一致,等价于下表:
105
+ 输出第一行是合并后的 tree hash(退出码非零表示有冲突,后续行给出冲突文件清单;冲突文件在结果 tree 里已含 `<<<<<<<` 标记)。脚本把结果 tree 检出到 tier 目录(`git archive <tree> | tar -x -C <to>`)即完成同步。逐文件语义与 git merge/rebase 完全一致,等价于下表:
96
106
 
97
107
  | 上游侧(base → 现在) | tier 侧(base → 现在) | 动作 |
98
108
  | --- | --- | --- |
@@ -106,72 +116,78 @@ git merge-tree --write-tree --merge-base=<baseTree> \
106
116
 
107
117
  只出现在 tier 侧、base 与上游都没有的文件,自动视为 **tier 私有**,永远不碰——`agents/`、`evals/`、langgraph 的 `package.json` 都落在这条规则里,无需任何配置。
108
118
 
109
- 排除项与特例:
119
+ **链式执行**:脚本对 pairs 拓扑排序后依次跑。上一对刚合并出的结果树(已在 git 对象库里)直接作为下一对的上游输入,所以一条命令就能从 origin 穿到 tier3,不要求中途提交;某一对报冲突时,它的整条下游跳过并计入失败,解完再跑一次即可续上。
110
120
 
111
- - `pnpm-lock.yaml` 从合并结果中剔除(检出时跳过),`node_modules/`、`.venv/` 本就未被 git 跟踪;合并后若 `package.json` / `pnpm-workspace.yaml` 有变动,在 tier 目录执行 `pnpm install` 重新生成 lockfile;
112
- - 二进制文件 git 无法文本合并,两侧都改过时会作为冲突报出,人工裁决;
113
- - 全部干净(或冲突已解)后,把该 pair 的 `baseTree` 更新为上游当前 tree hash,写回状态文件;
114
- - 收尾时导出阅读件:`git diff <上游tree> <tier tree> > examples/zh/diffs/<name>.patch`(排除 lockfile),这份 patch 是自动再生的**展示产物**,供快速阅读"接入改了什么",与 `gen:diff-code` 的文档页同源同性质,永远不作为同步输入。
121
+ **冲突与收尾**:冲突文件就地留 `<<<<<<<` 标记,脚本把"这次要合到的上游 tree"记进该 pair `pending`,列出清单并以非零码退出。人解完标记、**提交**之后重跑 `tiers:sync`:脚本看到 `pending` 且标记已清,直接把 `baseTree` 推进到当时要合的上游 tree、补上 `pnpm install` / patch 导出,**不重新合并**——重新合并会把同一处冲突再报一遍(见"已知取舍")。收尾之后若上游又前进了,同一次运行里继续正常合并追平。
115
122
 
116
- 链式同步按拓扑顺序:先 origin → tier1,再 tier1 → tier2。
123
+ 其余细节:
117
124
 
118
- 冲突处理与 git 一致:冲突文件就地留 `<<<<<<<` 标记,脚本列出清单并以非零码退出;人解完标记后重跑 `tiers:sync`(此时 tier 侧内容视为"改过",与上游一致或合并干净即收尾)
125
+ - 合并后若 `package.json` / `pnpm-workspace.yaml` 有变动,在 tier 目录执行 `pnpm install` 重新生成 lockfile(lockfile 本身从不进合并);
126
+ - 二进制文件 git 无法文本合并,两侧都改过时会作为冲突报出,人工裁决;
127
+ - 全部干净(或冲突已收尾)后,把该 pair 的 `baseTree` 更新为上游当前(剥 lockfile 的)tree hash,写回状态文件;
128
+ - 收尾时导出阅读件:`git diff <上游tree> <tier tree> > examples/zh/diffs/<层级>-<name>.patch`(如 `tier2-codex-sdk.patch`,同一应用的多层不撞名),这份 patch 是自动再生的**展示产物**,供快速阅读"这一层改了什么",与 `gen:diff-code` 的文档页同源同性质,永远不作为同步输入。
119
129
 
120
130
  ### 检查模式(`pnpm tiers:check`,进 CI)
121
131
 
122
- 不做合并,只做两件事,秒级完成:
132
+ 不做合并,只读,秒级完成,对每对做三件事:
123
133
 
124
- 1. 每对的 `baseTree` ≟ `git rev-parse HEAD:<from>`——不等即"上游变了但 tier 未同步",红,提示跑 `pnpm tiers:sync`;
125
- 2. 扫描 tier 跟踪文件中的 `<<<<<<<` 冲突标记,有则红。
134
+ 1. `baseTree` ≟ lockfile 后的 `HEAD:<from>`——不等即"上游变了但 tier 未同步",红,提示跑 `pnpm tiers:sync`;pair 带着未收尾的 `pending` 时同样红;
135
+ 2. 扫描 tier 跟踪文件中的 `<<<<<<<` 冲突标记,有则红;
136
+ 3. `contract: "verbatim"` 的对,额外做**逐字节铁律校验**:两侧都存在的同名文件必须完全一致、上游有的文件 tier 必须有(例外:三个脚手架文件、lockfile、`.env.example`)。直接在 tier1 里改共享文件并提交——以前这种漂移完全静默,现在 CI 红。
126
137
 
127
138
  ### 实现载体
128
139
 
129
- - `scripts/sync-tiers.mjs`(约 200 行粘合代码:读状态文件、调 `git merge-tree --write-tree`、检出结果、跑 `pnpm install`、导出 patch 阅读件),合并机制本身 100% 由 git 提供,不引第三方依赖;要求 git ≥ 2.38(`merge-tree --write-tree` 的最低版本);
130
- - `package.json` 增加 `"tiers:sync": "node scripts/sync-tiers.mjs sync"`、`"tiers:check": "node scripts/sync-tiers.mjs check"`;
131
- - CI(现有 lint/typecheck 步骤旁)加一步 `pnpm tiers:check`。
140
+ - `scripts/sync-tiers.mjs`(约 300 行粘合代码:读写状态文件、剥 lockfile、调 `git merge-tree --write-tree`、拓扑排序、检出结果、pending 收尾、跑 `pnpm install`、导出 patch 阅读件),合并机制本身 100% 由 git 提供,不引第三方依赖;要求 git ≥ 2.38(`merge-tree --write-tree` 的最低版本);
141
+ - `package.json` `"tiers:sync"` / `"tiers:check"` 两个 script;
142
+ - CI(现有 lint/typecheck 步骤旁)一步 `pnpm tiers:check`。
132
143
 
133
144
  ## 日常工作流(before / after)
134
145
 
135
- 改 origin 应用源码——现在:
146
+ 改 origin 应用源码——没有这套机制时:
136
147
 
137
148
  ```sh
138
149
  vim examples/zh/origin/codex-sdk/src/backend/agent.ts
139
- # 然后必须人肉记得 tier1 有一份同样的文件:
140
- cp examples/zh/origin/codex-sdk/src/backend/agent.ts \
141
- examples/zh/tier1/codex-sdk/src/backend/agent.ts
142
- # 忘了 cp?没有任何检查会发现,diff 文档页从此静默失真。
150
+ # 然后必须人肉记得 tier1、tier2、tier3 各有一份同样的文件:
151
+ cp examples/zh/origin/codex-sdk/src/backend/agent.ts examples/zh/tier1/codex-sdk/src/backend/agent.ts
152
+ cp examples/zh/origin/codex-sdk/src/backend/agent.ts examples/zh/tier2/codex-sdk/src/backend/agent.ts
153
+ # tier3 改过这个文件?那 cp 会盖掉 tier3 的侵入改造,只能打开手工对……
154
+ # 忘了哪一层?没有任何检查会发现,diff 文档页从此静默失真。
143
155
  ```
144
156
 
145
- 改 origin 应用源码——方案落地后:
157
+ 方案落地后:
146
158
 
147
159
  ```sh
148
160
  vim examples/zh/origin/codex-sdk/src/backend/agent.ts
149
161
  git add examples/zh/origin/codex-sdk && git commit -m "..."
150
- pnpm tiers:sync # tier1 rebase origin;必要时自动 pnpm install
151
- git diff --stat # review tier1 侧的机器改动
162
+ pnpm tiers:sync codex-sdk # 一条命令穿透 tier1 tier2 → tier3;必要时自动 pnpm install
163
+ git diff --stat # review 各层的机器改动
152
164
  git add -A examples/zh && git commit -m "sync tiers"
153
165
  ```
154
166
 
155
167
  改 tier 私有文件(evals / agents / README):直接改,与同步机制无关。
156
168
 
157
- tier1/tier2 里"应该跟上游逐字节一致"的共享文件(比如临时改一行 `src/backend/server.ts` 去验证个问题):这条路径不是方案设计要覆盖的——前提就是"tier 从不改 src/"(见"采纳三方合并的正面理由"),所以没有专门命令。改完之后有两条路:要么把改动誊回对应的 origin 文件、提交、再跑一遍 `tiers:sync` 走正常快进,让改动"转正"成一次 origin 变更;要么先放着,等下次 origin 改到同一文件同一处时被 `tiers:sync` 报冲突——冲突文件里是 `<<<<<<<` 标记,把两边改动合到一起、删掉标记、重跑 `tiers:sync` 收尾,和解普通 git rebase 冲突是同一套动作。
169
+ tier 层的 overlay 文件(tier2 `niceeval.config.ts`、tier3 的 `src/backend/agent.ts` 这类"从上游复制来但本层改过"的文件):也是直接改——这些文件本来就是本层 delta 的一部分,下次上游动到同一文件时三方合并会把两边的改动合在一起,同点冲突时显式报出。
170
+
171
+ 在 tier1/tier2 里改"应该跟上游逐字节一致"的共享文件(比如临时改一行 `src/backend/server.ts` 去验证个问题):这不是方案要覆盖的路径——tier1/tier2 的前提就是不改应用源码。改完把改动誊回对应的 origin 文件、提交、跑 `tiers:sync` 让它"转正"成一次 origin 变更;忘了誊、直接提交,`tiers:check` 的 verbatim 校验会红(tier1),或者留给下次同步撞冲突(tier2)。
158
172
 
159
- 反过来,如果是在 tier1/tier2 里先调试出的修复,想让 origin 也拿到:同步方向是单向的 origin → tier1 → tier2,没有 backport 命令。得手工把改动誊回 origin 对应文件、提交、跑 `tiers:sync`,让 origin 补上这次变更、baseTree 也随之前进——不然下次同步会把这处改动误判成冲突。
173
+ 反过来,如果是在下游先调试出的修复,想让上游也拿到:同步方向是单向的,没有 backport 命令。得手工把改动誊回上游对应文件、提交、跑 `tiers:sync` 让链条追平——不然下次同步会把这处改动误判成冲突。
160
174
 
161
- origin 给应用加依赖(动了 `package.json`)——三方合并把新依赖行合进 tier1 的 `package.json`(tier 自己的 `"niceeval": "file:../../../.."` 在另一行,不冲突),随后自动 `pnpm install` 更新 lockfile
175
+ origin 给应用加依赖(动了 `package.json`)——三方合并把新依赖行合进 tier1 的 `package.json`(tier 自己的 `"niceeval": "file:../../../.."` 在另一行,不冲突),随后自动 `pnpm install` 更新 lockfile,tier2/tier3 同样各自重装。
162
176
 
163
177
  忘了同步就提交?CI 的 `tiers:check` 红:
164
178
 
165
179
  ```text
166
- ✗ examples/zh/tier1/codex-sdk 落后于 origin/codex-sdk
180
+ ✗ examples/zh/tier1/codex-sdk 落后于 examples/zh/origin/codex-sdk
167
181
  base a1b2c3… ≠ 当前 9f8e7d…,运行 pnpm tiers:sync 后重新提交
168
182
  ```
169
183
 
170
- ## 验收标准(实现时逐条核对)
184
+ ## 验收标准(实现时逐条核对,已全部沙盘验证)
171
185
 
172
- 1. 对现有五个示例首次初始化 base 后,`tiers:sync` 是无操作(两边已一致);
173
- 2. 改 origin 任一 `src/` 文件 → sync 后 tier1 同文件逐字节一致,`gen:diff-code` 输出不含该文件;
174
- 3. 改 origin `package.json`(加一个依赖)→ sync tier1 的 `package.json` 同时含新依赖与 niceeval 集成行,lockfile 已重装;
175
- 4. origin 与 tier1 在同一文件同一区域都有改动 → sync 报冲突、留标记、非零退出;`tiers:check` 在标记未解前保持红;
176
- 5. langgraph(origin 为 Python、`package.json` tier 私有)全流程不误伤 tier 私有文件;
177
- 6. `tiers:check` base 落后时红、同步后绿,全程不写任何文件。
186
+ 1. 对现有目录初始化 base 后,`tiers:sync` 是无操作(各层已一致),`tiers:check` 绿;
187
+ 2. 改 origin 任一 `src/` 文件 → **一次** sync 后 tier1/tier2/tier3 同文件全部跟上(tier3 该文件有 overlay 改动时做真合并),中途无需提交;`gen:diff-code` 的 origin↔tier1 页不含该文件;
188
+ 3. 改 origin `package.json`(加一个依赖)→ sync 后各层 `package.json` 同时含新依赖与本层集成行,lockfile 已重装;
189
+ 4. 上游与下游在同一文件同一区域都有改动 → sync 报冲突、留标记、记 `pending`、非零退出,该应用的下游层跳过;`tiers:check` 在标记未解前保持红;
190
+ 5. 解完标记、提交、重跑 `tiers:sync` 直接收尾(不重报同一冲突),baseTree 前进,同一次运行里下游层继续同步;
191
+ 6. 直接改 tier1 的共享文件并提交 → `tiers:check` verbatim 校验红,指出具体文件;
192
+ 7. langgraph(origin 为 Python、`package.json` 为 tier 私有)全流程不误伤 tier 私有文件;
193
+ 8. `tiers:check` 在 base 落后时红、同步后绿,全程不写任何文件。