niceeval 0.6.1 → 0.6.2

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 (296) hide show
  1. package/dist/agents/types.d.ts +67 -5
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -1
  9. package/dist/report/built-ins/experiment-comparison.js +116 -10
  10. package/dist/report/built-ins/index.d.ts +1 -0
  11. package/dist/report/built-ins/index.js +1 -1
  12. package/dist/report/components.d.ts +8 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +54 -34
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -8
  19. package/dist/report/format.js +27 -12
  20. package/dist/report/index.d.ts +4 -3
  21. package/dist/report/index.js +5 -4
  22. package/dist/report/locale.d.ts +11 -2
  23. package/dist/report/locale.js +23 -5
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +65 -14
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +2 -2
  28. package/dist/report/react/AttemptList.js +5 -6
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +2 -1
  34. package/dist/report/react/ExperimentList.js +8 -10
  35. package/dist/report/react/MetricScatter.js +5 -11
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +21 -14
  40. package/dist/report/report.d.ts +5 -1
  41. package/dist/report/report.js +6 -2
  42. package/dist/report/text/faces.d.ts +1 -1
  43. package/dist/report/text/faces.js +42 -41
  44. package/dist/report/text/table.js +36 -5
  45. package/dist/report/types.d.ts +39 -21
  46. package/dist/results/types.d.ts +11 -0
  47. package/dist/runner/feedback/sink.d.ts +110 -0
  48. package/dist/runner/types.d.ts +513 -22
  49. package/dist/sandbox/docker.d.ts +23 -2
  50. package/dist/sandbox/e2b.d.ts +15 -1
  51. package/dist/sandbox/errors.d.ts +30 -3
  52. package/dist/sandbox/io-retry.d.ts +17 -0
  53. package/dist/sandbox/registry.d.ts +2 -0
  54. package/dist/sandbox/resolve.d.ts +18 -5
  55. package/dist/sandbox/retry.d.ts +11 -1
  56. package/dist/sandbox/types.d.ts +39 -5
  57. package/dist/sandbox/vercel.d.ts +7 -1
  58. package/dist/scoring/coverage.d.ts +30 -0
  59. package/dist/scoring/display.d.ts +21 -0
  60. package/dist/scoring/display.js +120 -0
  61. package/dist/scoring/types.d.ts +103 -20
  62. package/dist/shared/aggregate.d.ts +1 -0
  63. package/dist/shared/aggregate.js +3 -3
  64. package/dist/shared/types.d.ts +28 -0
  65. package/dist/tty-line.d.ts +0 -4
  66. package/dist/util.d.ts +23 -0
  67. package/docs-site/zh/concepts/adapter.mdx +22 -4
  68. package/docs-site/zh/concepts/experiment.mdx +1 -1
  69. package/docs-site/zh/concepts/overview.mdx +6 -6
  70. package/docs-site/zh/guides/agent-feedback-loop.mdx +28 -26
  71. package/docs-site/zh/guides/authoring.mdx +33 -0
  72. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  73. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  74. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  75. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  76. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  77. package/docs-site/zh/guides/debugging.mdx +210 -0
  78. package/docs-site/zh/guides/experiments.mdx +10 -3
  79. package/docs-site/zh/guides/official-adapters.mdx +26 -2
  80. package/docs-site/zh/guides/publish-report.mdx +30 -16
  81. package/docs-site/zh/guides/report-components.mdx +42 -30
  82. package/docs-site/zh/guides/reporters.mdx +2 -2
  83. package/docs-site/zh/guides/results-data.mdx +17 -9
  84. package/docs-site/zh/guides/runner.mdx +17 -7
  85. package/docs-site/zh/guides/sandbox-agent.mdx +56 -7
  86. package/docs-site/zh/guides/sandbox-providers.mdx +257 -9
  87. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  88. package/docs-site/zh/guides/viewing-results.mdx +79 -36
  89. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  90. package/docs-site/zh/guides/write-send.mdx +17 -1
  91. package/docs-site/zh/index.mdx +1 -1
  92. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  93. package/docs-site/zh/reference/capabilities.mdx +2 -2
  94. package/docs-site/zh/reference/cli.mdx +33 -7
  95. package/docs-site/zh/reference/define-agent.mdx +57 -4
  96. package/docs-site/zh/reference/define-config.mdx +1 -1
  97. package/docs-site/zh/reference/define-eval.mdx +42 -9
  98. package/docs-site/zh/reference/expect.mdx +26 -1
  99. package/package.json +5 -1
  100. package/src/agents/ai-sdk-otel.test.ts +1 -0
  101. package/src/agents/ai-sdk.test.ts +3 -0
  102. package/src/agents/ai-sdk.ts +3 -0
  103. package/src/agents/bub-install-spec.test.ts +34 -0
  104. package/src/agents/bub-install-spec.ts +32 -0
  105. package/src/agents/bub.ts +31 -32
  106. package/src/agents/claude-code.test.ts +130 -9
  107. package/src/agents/claude-code.ts +76 -4
  108. package/src/agents/codex.test.ts +189 -40
  109. package/src/agents/codex.ts +155 -14
  110. package/src/agents/coding-cli-versions.test.ts +15 -0
  111. package/src/agents/coding-cli-versions.ts +3 -0
  112. package/src/agents/index.ts +11 -0
  113. package/src/agents/langgraph.test.ts +204 -0
  114. package/src/agents/langgraph.ts +495 -0
  115. package/src/agents/marketplace.ts +85 -0
  116. package/src/agents/native-config.test.ts +179 -0
  117. package/src/agents/native-config.ts +267 -0
  118. package/src/agents/openai-compat.test.ts +1 -0
  119. package/src/agents/openclaw.test.ts +31 -0
  120. package/src/agents/openclaw.ts +171 -0
  121. package/src/agents/plugin-config.test.ts +1 -0
  122. package/src/agents/sdk-streams.test.ts +79 -0
  123. package/src/agents/sdk-streams.ts +55 -10
  124. package/src/agents/skills.test.ts +1 -0
  125. package/src/agents/streaming.test.ts +3 -9
  126. package/src/agents/types.ts +68 -5
  127. package/src/agents/ui-message-stream.test.ts +3 -0
  128. package/src/cli.ts +411 -108
  129. package/src/context/context.test.ts +51 -12
  130. package/src/context/context.ts +161 -29
  131. package/src/context/session.test.ts +1 -0
  132. package/src/context/session.ts +114 -6
  133. package/src/context/types.ts +30 -12
  134. package/src/define.test.ts +13 -8
  135. package/src/define.ts +25 -4
  136. package/src/expect/index.ts +53 -23
  137. package/src/i18n/en.ts +64 -2
  138. package/src/i18n/zh-CN.ts +65 -3
  139. package/src/o11y/cost.test.ts +1 -0
  140. package/src/o11y/execution-tree.test.ts +1 -20
  141. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  142. package/src/o11y/otlp/parse.test.ts +1 -0
  143. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  144. package/src/o11y/parsers/bub.test.ts +1 -0
  145. package/src/o11y/parsers/claude-code.test.ts +1 -34
  146. package/src/o11y/parsers/openclaw.test.ts +154 -0
  147. package/src/o11y/parsers/openclaw.ts +310 -0
  148. package/src/o11y/prices.json +746 -311
  149. package/src/o11y/tool-names.test.ts +1 -0
  150. package/src/o11y/types.ts +16 -2
  151. package/src/report/aggregate.ts +34 -5
  152. package/src/report/built-in-user-parity.test.tsx +110 -153
  153. package/src/report/built-ins/experiment-comparison.tsx +173 -13
  154. package/src/report/built-ins/index.ts +6 -1
  155. package/src/report/components.tsx +9 -3
  156. package/src/report/compute.ts +70 -40
  157. package/src/report/dual-render.test.tsx +194 -67
  158. package/src/report/flag.ts +30 -2
  159. package/src/report/format.ts +35 -11
  160. package/src/report/index.ts +22 -4
  161. package/src/report/locale.ts +25 -5
  162. package/src/report/metrics.ts +67 -14
  163. package/src/report/primitives.tsx +6 -0
  164. package/src/report/react/AttemptList.tsx +6 -31
  165. package/src/report/react/EvalList.tsx +0 -0
  166. package/src/report/react/ExperimentComparison.tsx +68 -0
  167. package/src/report/react/ExperimentList.tsx +15 -9
  168. package/src/report/react/MetricScatter.tsx +12 -14
  169. package/src/report/react/chart-math.test.ts +85 -0
  170. package/src/report/react/chart-math.ts +101 -22
  171. package/src/report/react/enhance.js +33 -1
  172. package/src/report/react/fixtures.ts +24 -17
  173. package/src/report/react/render.test.tsx +9 -64
  174. package/src/report/react/styles.css +73 -2
  175. package/src/report/report.test.ts +306 -98
  176. package/src/report/report.ts +6 -2
  177. package/src/report/text/faces.ts +47 -43
  178. package/src/report/text/table.ts +42 -5
  179. package/src/report/types.ts +41 -21
  180. package/src/results/annotated-source.test.ts +62 -9
  181. package/src/results/annotated-source.ts +64 -6
  182. package/src/results/attempt-evidence.test.ts +9 -7
  183. package/src/results/attempt-evidence.ts +15 -8
  184. package/src/results/attempt-source.ts +6 -3
  185. package/src/results/copy.ts +145 -55
  186. package/src/results/host-equivalence.test.ts +8 -6
  187. package/src/results/index.ts +2 -0
  188. package/src/results/locator.test.ts +1 -22
  189. package/src/results/open.ts +7 -1
  190. package/src/results/publish.ts +149 -0
  191. package/src/results/results.test.ts +85 -51
  192. package/src/results/truncate.ts +90 -0
  193. package/src/results/types.ts +7 -0
  194. package/src/results/writer.ts +31 -13
  195. package/src/runner/attempt.test.ts +138 -7
  196. package/src/runner/attempt.ts +603 -104
  197. package/src/runner/discover.test.ts +47 -0
  198. package/src/runner/discover.ts +36 -2
  199. package/src/runner/eval-source.test.ts +1 -27
  200. package/src/runner/feedback/agent.test.ts +504 -0
  201. package/src/runner/feedback/agent.ts +409 -0
  202. package/src/runner/feedback/ci.test.ts +562 -0
  203. package/src/runner/feedback/ci.ts +401 -0
  204. package/src/runner/feedback/coordinator.test.ts +317 -0
  205. package/src/runner/feedback/coordinator.ts +397 -0
  206. package/src/runner/feedback/failure.ts +40 -0
  207. package/src/runner/feedback/human.test.ts +616 -0
  208. package/src/runner/feedback/human.ts +535 -0
  209. package/src/runner/feedback/index.ts +66 -0
  210. package/src/runner/feedback/io.ts +78 -0
  211. package/src/runner/feedback/profile.test.ts +50 -0
  212. package/src/runner/feedback/profile.ts +58 -0
  213. package/src/runner/feedback/reducer.test.ts +395 -0
  214. package/src/runner/feedback/reducer.ts +260 -0
  215. package/src/runner/feedback/renderer.ts +82 -0
  216. package/src/runner/feedback/sink.ts +203 -0
  217. package/src/runner/feedback/testing.ts +106 -0
  218. package/src/runner/ledger.test.ts +230 -0
  219. package/src/runner/ledger.ts +329 -0
  220. package/src/runner/report.test.ts +128 -3
  221. package/src/runner/report.ts +33 -9
  222. package/src/runner/reporters/artifacts.ts +8 -2
  223. package/src/runner/reporters/braintrust.test.ts +8 -7
  224. package/src/runner/reporters/braintrust.ts +9 -2
  225. package/src/runner/reporters/index.ts +2 -2
  226. package/src/runner/reporters/json.test.ts +162 -0
  227. package/src/runner/reporters/json.ts +35 -8
  228. package/src/runner/reporters/shared.ts +1 -5
  229. package/src/runner/run.test.ts +760 -3
  230. package/src/runner/run.ts +242 -36
  231. package/src/runner/sandbox-prep.ts +3 -42
  232. package/src/runner/timing.ts +158 -0
  233. package/src/runner/types.ts +518 -22
  234. package/src/sandbox/checkpoint.test.ts +55 -0
  235. package/src/sandbox/checkpoint.ts +29 -8
  236. package/src/sandbox/cli-commands.ts +407 -0
  237. package/src/sandbox/docker.ts +115 -16
  238. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  239. package/src/sandbox/e2b-agent-template.ts +94 -0
  240. package/src/sandbox/e2b.ts +74 -9
  241. package/src/sandbox/errors.ts +111 -4
  242. package/src/sandbox/index.ts +2 -0
  243. package/src/sandbox/io-retry.test.ts +58 -0
  244. package/src/sandbox/io-retry.ts +45 -0
  245. package/src/sandbox/keep-registry.test.ts +86 -0
  246. package/src/sandbox/keep-registry.ts +142 -0
  247. package/src/sandbox/keep.ts +178 -0
  248. package/src/sandbox/paths.test.ts +1 -0
  249. package/src/sandbox/paths.ts +19 -8
  250. package/src/sandbox/registry.ts +20 -3
  251. package/src/sandbox/resolve.ts +76 -11
  252. package/src/sandbox/retry.test.ts +70 -0
  253. package/src/sandbox/retry.ts +46 -4
  254. package/src/sandbox/types.ts +44 -6
  255. package/src/sandbox/vercel.ts +43 -20
  256. package/src/scoring/collector.ts +60 -17
  257. package/src/scoring/coverage.ts +95 -0
  258. package/src/scoring/diff.ts +81 -0
  259. package/src/scoring/display.test.ts +121 -0
  260. package/src/scoring/display.ts +133 -0
  261. package/src/scoring/evidence.test.ts +189 -0
  262. package/src/scoring/judge.test.ts +142 -0
  263. package/src/scoring/judge.ts +15 -18
  264. package/src/scoring/scoped.ts +217 -50
  265. package/src/scoring/types.ts +117 -20
  266. package/src/scoring/verdict.ts +16 -4
  267. package/src/shared/aggregate.ts +3 -2
  268. package/src/shared/types.ts +31 -0
  269. package/src/show/compose.ts +2 -2
  270. package/src/show/index.ts +21 -1
  271. package/src/show/render.ts +619 -104
  272. package/src/show/show.test.ts +235 -19
  273. package/src/tty-line.ts +8 -26
  274. package/src/util.test.ts +1 -0
  275. package/src/util.ts +41 -0
  276. package/src/view/app/components/AttemptModal.tsx +153 -2
  277. package/src/view/app/components/CodeView.tsx +32 -11
  278. package/src/view/app/components/CopyControls.tsx +2 -2
  279. package/src/view/app/i18n.ts +6 -0
  280. package/src/view/app/lib/attempt-route.test.ts +1 -0
  281. package/src/view/app/lib/verdict.ts +7 -9
  282. package/src/view/artifact-serving.test.ts +2 -1
  283. package/src/view/client-dist/app.css +1 -1
  284. package/src/view/client-dist/app.js +17 -17
  285. package/src/view/data.test.ts +1 -0
  286. package/src/view/data.ts +11 -1
  287. package/src/view/index.ts +11 -0
  288. package/src/view/server.ts +2 -0
  289. package/src/view/styles.css +3 -0
  290. package/src/view/view-report.test.ts +6 -5
  291. package/src/runner/reporters/console.ts +0 -70
  292. package/src/runner/reporters/live.test.ts +0 -56
  293. package/src/runner/reporters/live.ts +0 -247
  294. package/src/runner/reporters/quiet.test.ts +0 -66
  295. package/src/runner/reporters/quiet.ts +0 -49
  296. package/src/runner/reporters/table.ts +0 -277
package/src/cli.ts CHANGED
@@ -5,23 +5,36 @@
5
5
  // niceeval clean 删除 .niceeval/ 历史运行 artifact
6
6
 
7
7
  import { spawn } from "node:child_process";
8
+ import { createHash } from "node:crypto";
8
9
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
10
  import { existsSync } from "node:fs";
10
- import { join, relative } from "node:path";
11
+ import { join, relative, resolve as resolvePath } from "node:path";
11
12
  import { pathToFileURL } from "node:url";
12
13
  import { parseArgs as nodeParseArgs } from "node:util";
13
14
  import { discoverEvals, discoverExperiments, makeFilter } from "./runner/discover.ts";
14
15
  import { runEvals, type AgentRun } from "./runner/run.ts";
15
- import { runWho } from "./runner/types.ts";
16
16
  import { planCarry } from "./runner/fingerprint.ts";
17
+ import { failureDetailFromResult } from "./runner/feedback/failure.ts";
17
18
  import { stopAllSandboxes, liveSandboxCount } from "./sandbox/registry.ts";
18
19
  import { evalLevelStats } from "./shared/verdict.ts";
19
20
  import { sandboxRecommendedConcurrency } from "./sandbox/resolve.ts";
20
- import { Console as ConsoleReporter } from "./runner/reporters/console.ts";
21
- import { Quiet as QuietReporter } from "./runner/reporters/quiet.ts";
22
21
  import { Json, JUnit } from "./runner/reporters/json.ts";
23
- import { Live as LiveReporter, type LiveRow } from "./runner/reporters/live.ts";
24
22
  import { Artifacts as ArtifactsReporter } from "./runner/reporters/artifacts.ts";
23
+ import {
24
+ resolveOutputProfile,
25
+ createFeedbackCoordinator,
26
+ createNodeFeedbackIO,
27
+ createHumanRenderer,
28
+ createAgentRenderer,
29
+ createCiRenderer,
30
+ renderAgentPlanEnvelope,
31
+ renderHumanDryPlan,
32
+ renderCiDryPlan,
33
+ computeCiExitCode,
34
+ reportActivity,
35
+ type OutputProfileFlag,
36
+ type AgentPlanRow,
37
+ } from "./runner/feedback/index.ts";
25
38
  import {
26
39
  buildView,
27
40
  startViewServer,
@@ -38,7 +51,17 @@ import { ReportLoadError } from "../dist/report/load.js";
38
51
  import { runShow } from "./show/index.ts";
39
52
  import { t } from "./i18n/index.ts";
40
53
  import { formatThrown, upsertManagedBlock } from "./util.ts";
41
- import type { Config, DiscoveredExperiment, Reporter } from "./types.ts";
54
+ import type {
55
+ CompletionStatus,
56
+ Config,
57
+ DiscoveredExperiment,
58
+ ReporterError,
59
+ ReporterRegistration,
60
+ RunCompletion,
61
+ RunFeedbackPlan,
62
+ RunFeedbackState,
63
+ RunSummary,
64
+ } from "./types.ts";
42
65
 
43
66
  /**
44
67
  * view 的可预期用户错误:版本不同的报告(npx 提示)、位置参数/组合语义错误、
@@ -60,7 +83,8 @@ interface Flags {
60
83
  timeout?: number;
61
84
  earlyExit?: boolean;
62
85
  dry: boolean;
63
- quiet: boolean;
86
+ /** 反馈 profile,已解析/校验(`auto` 默认值也算已解析——具体环境判定见 `resolveOutputProfile`)。 */
87
+ output: OutputProfileFlag;
64
88
  force: boolean;
65
89
  strict: boolean;
66
90
  budget?: number;
@@ -78,6 +102,13 @@ interface Flags {
78
102
  diff: boolean;
79
103
  /** --diff=<路径>(必须 = 连写;空格形式会把路径当 eval id 前缀,按文档如此)。 */
80
104
  diffPath?: string;
105
+ timing?: "summary" | "full";
106
+ keepSandbox?: "failed" | "all";
107
+ all: boolean;
108
+ allowSensitiveArtifacts: boolean;
109
+ window?: string;
110
+ sandboxPath?: string;
111
+ leaveRunning: boolean;
81
112
  history: boolean;
82
113
  experiment?: string;
83
114
  run?: string;
@@ -103,6 +134,16 @@ const FLAG_OPTIONS = {
103
134
  timeout: { type: "string" },
104
135
  /** 整次运行的预算上限(美元)。 */
105
136
  budget: { type: "string" },
137
+ /** `exp` 命令专用:跑完留下 failed/errored attempt 的沙箱现场(= `--keep-sandbox=failed`);`--keep-sandbox=all` 连 passed 也留。事后用 `niceeval sandbox list/enter/stop` 查看与销毁。 */
138
+ "keep-sandbox": { type: "boolean" },
139
+ /** `sandbox stop` 专用:销毁全部留存沙箱。 */
140
+ all: { type: "boolean" },
141
+ /** `sandbox diff` 专用:只看某个 send 窗口(如 `--window s1/t2`);省略输出全部窗口的串联视图。 */
142
+ window: { type: "string" },
143
+ /** `sandbox diff` 专用:只看某个文件的 patch;省略输出该窗口的全部文件。 */
144
+ path: { type: "string" },
145
+ /** `sandbox enter` 专用:shell 退出后让现场保持运行,不送回休眠。 */
146
+ "leave-running": { type: "boolean" },
106
147
  /** 只运行带有该 tag 的 eval(见 `defineEval` 的 `tags`)。 */
107
148
  tag: { type: "string" },
108
149
  /** 额外写一份 JUnit XML 报告到指定路径,供 CI 消费。 */
@@ -113,6 +154,8 @@ const FLAG_OPTIONS = {
113
154
  out: { type: "string" },
114
155
  /** `view` 命令专用:指定本地服务器监听端口。 */
115
156
  port: { type: "string" },
157
+ /** `view --out` 专用:对非发布根(快照没有 publish:{redaction:"applied"} 标记)导出时的显式确认——静态站会原样携带未消毒的证据文件。 */
158
+ "allow-sensitive-artifacts": { type: "boolean" },
116
159
  // show 的证据切面 / 时间轴 / 报告装载(docs-site/zh/guides/viewing-results.mdx)。
117
160
  // 证据切面只认 `@<locator>`(或收窄到单个 eval 的前缀)选出的那一个 attempt——不再有
118
161
  // 数字 `--attempt`,选哪个 attempt 由 locator 精确指名,不是「先选 eval 再挑第几次」。
@@ -120,22 +163,24 @@ const FLAG_OPTIONS = {
120
163
  eval: { type: "boolean" },
121
164
  /** `show` 命令专用:该 attempt 的标准执行事件流(消息、thinking、Skill load、工具调用/结果);有 OTel 时同一节点补时间(证据切面)。 */
122
165
  execution: { type: "boolean" },
166
+ /** `show` 命令专用:整个 Attempt 的统一时间树;裸 `--timing` 给有界诊断投影,`--timing=full` 逐节点展开全部 runner/已关联 OTel 节点。 */
167
+ timing: { type: "boolean" },
123
168
  // --diff 是布尔;--diff=<路径> 在 parseArgs 前预扫成 diffPath(路径必须 = 连写,
124
169
  // 空格形式的下一个 token 仍是位置参数 = eval id 前缀,与文档一致)。
125
170
  /** `show` 命令专用:sandbox 里的文件改动摘要;`--diff=<文件路径>` 看单个文件的完整改动(路径必须 `=` 连写)。 */
126
171
  diff: { type: "boolean" },
127
172
  /** `show` 命令专用:跨 run 时间轴,只列真实执行;与 `--report` 互斥。 */
128
173
  history: { type: "boolean" },
129
- /** `show` / `view` 命令专用:Selection 只留该实验。 */
174
+ /** `show` / `view` 命令专用:按路径段前缀收窄 experiment;组名会选中组内全部配置。 */
130
175
  experiment: { type: "string" },
131
176
  /** `show` / `view` 命令专用:钉死看某一个结果目录(某次快照或 `copySnapshots` 产物)。 */
132
177
  run: { type: "string" },
133
- /** `show` / `view` 命令专用:渲染你的报告文件(文件默认导出 `defineReport(...)`);show 用它替换 Attempt 索引,view 用它替换默认分析报告。 */
178
+ /** `show` / `view` 命令专用:用文件默认导出的 `defineReport(...)` 替换两者共用的默认报告。 */
134
179
  report: { type: "string" },
135
- /** 只打印本次会匹配到的 eval × 运行配置,不实际执行。 */
180
+ /** 只打印本次会匹配到的 eval × 运行配置,不实际执行(按下面 `--output` 选中的 profile 给出预览)。 */
136
181
  dry: { type: "boolean" },
137
- /** 关闭控制台 / live 的逐条结果与末尾汇总(attempt 进度行仍写 stderr);errored / failed 的结果各在 stderr 补一行摘要,passed / skipped 静默;reporter 仍会写 artifacts。 */
138
- quiet: { type: "boolean" },
182
+ /** 反馈 profile:`auto`(默认)按环境自动选择,`human` / `agent` / `ci` 强制指定;只改变终端展示,不改变选择、调度、判定、artifact 或退出码。`auto` 依次判定:stderr TTY human;否则 `CI`(或其它常见 CI 平台环境变量)存在 ci;否则 → agent。 */
183
+ output: { type: "string" },
139
184
  /** 忽略上次运行结果,不跳过已通过的 (experiment, eval) 组合,强制全部重跑。 */
140
185
  force: { type: "boolean" },
141
186
  /** CI 中推荐使用:让软阈值(`soft`)失败也计入整条 eval 的 verdict。 */
@@ -162,17 +207,50 @@ function numberFlag(name: string, raw: string | undefined): number | undefined {
162
207
  return n;
163
208
  }
164
209
 
210
+ const OUTPUT_PROFILE_VALUES = ["auto", "human", "agent", "ci"] as const;
211
+
212
+ /** `--output` 解析仅接受 `auto|human|agent|ci`;非法值直接报清晰用法并退出,不静默回退到某个默认值。 */
213
+ function outputFlag(raw: string | undefined): OutputProfileFlag {
214
+ if (raw === undefined) return "auto";
215
+ if ((OUTPUT_PROFILE_VALUES as readonly string[]).includes(raw)) return raw as OutputProfileFlag;
216
+ process.stderr.write(t("cli.flag.invalidOutput", { value: raw }));
217
+ process.exit(1);
218
+ }
219
+
165
220
  function parseArgs(argv: string[]): { command: string; positionals: string[]; flags: Flags } {
166
221
  if (argv[0] === "--") argv = argv.slice(1);
167
222
 
168
223
  // --diff=<路径> 预扫:diff 本体是布尔(裸 --diff = 文件级摘要),路径只接受 = 连写。
169
224
  let diffPath: string | undefined;
225
+ // --keep-sandbox[=failed|all] 预扫:本体是布尔(裸 = failed 档),档位只接受 = 连写。
226
+ let keepSandboxTier: "failed" | "all" | undefined;
227
+ // --timing[=summary|full] 预扫:node:util 的单个 option 不支持 boolean|string 联合,
228
+ // 所以 mode 在严格 parseArgs 前提取,再把两种形式统一成布尔 --timing。
229
+ let timingMode: "summary" | "full" | undefined;
170
230
  argv = argv.map((arg) => {
171
231
  if (arg.startsWith("--diff=")) {
172
232
  const path = arg.slice("--diff=".length);
173
233
  if (path) diffPath = path;
174
234
  return "--diff";
175
235
  }
236
+ if (arg.startsWith("--keep-sandbox=")) {
237
+ const tier = arg.slice("--keep-sandbox=".length);
238
+ if (tier !== "failed" && tier !== "all") {
239
+ process.stderr.write(`--keep-sandbox only accepts "failed" (default) or "all", got "${tier}".\n`);
240
+ process.exit(1);
241
+ }
242
+ keepSandboxTier = tier;
243
+ return "--keep-sandbox";
244
+ }
245
+ if (arg.startsWith("--timing=")) {
246
+ const mode = arg.slice("--timing=".length);
247
+ if (mode !== "summary" && mode !== "full") {
248
+ process.stderr.write(`--timing only accepts "summary" (default) or "full", got "${mode}".\n`);
249
+ process.exit(1);
250
+ }
251
+ timingMode = mode;
252
+ return "--timing";
253
+ }
176
254
  return arg;
177
255
  });
178
256
 
@@ -188,7 +266,7 @@ function parseArgs(argv: string[]): { command: string; positionals: string[]; fl
188
266
  }
189
267
 
190
268
  // 第一个位置参数若是已知命令,则为命令;其余是 eval id 前缀 / view 输入。
191
- const commands = new Set(["exp", "show", "list", "view", "clean", "init", "watch", "run"]);
269
+ const commands = new Set(["exp", "show", "list", "view", "clean", "init", "watch", "run", "sandbox"]);
192
270
  let command = "run";
193
271
  let positionals = rawPositionals;
194
272
  if (rawPositionals[0] && commands.has(rawPositionals[0])) {
@@ -209,7 +287,7 @@ function parseArgs(argv: string[]): { command: string; positionals: string[]; fl
209
287
  out: values.out as string | undefined,
210
288
  port: numberFlag("port", values.port as string | undefined),
211
289
  dry: values.dry === true,
212
- quiet: values.quiet === true,
290
+ output: outputFlag(values.output as string | undefined),
213
291
  force: values.force === true,
214
292
  strict: values.strict === true,
215
293
  earlyExit: values["no-early-exit"] === true ? false : values["early-exit"] === true ? true : undefined,
@@ -220,6 +298,13 @@ function parseArgs(argv: string[]): { command: string; positionals: string[]; fl
220
298
  execution: values.execution === true,
221
299
  diff: values.diff === true && diffPath === undefined,
222
300
  diffPath,
301
+ timing: values.timing === true ? (timingMode ?? "summary") : undefined,
302
+ keepSandbox: values["keep-sandbox"] === true ? (keepSandboxTier ?? "failed") : undefined,
303
+ all: values.all === true,
304
+ allowSensitiveArtifacts: values["allow-sensitive-artifacts"] === true,
305
+ window: values.window as string | undefined,
306
+ sandboxPath: values.path as string | undefined,
307
+ leaveRunning: values["leave-running"] === true,
223
308
  history: values.history === true,
224
309
  experiment: values.experiment as string | undefined,
225
310
  run: values.run as string | undefined,
@@ -228,6 +313,31 @@ function parseArgs(argv: string[]): { command: string; positionals: string[]; fl
228
313
  return { command, positionals, flags };
229
314
  }
230
315
 
316
+ /**
317
+ * exp 只接受两类输入:位置参数选「跑哪些 eval」+ 调度/输出/机器出口 flag 选「对着哪个 agent、
318
+ * 怎么跑」。show / view 专属的证据切面(`--eval`/`--execution`/`--diff`)、时间轴(`--history`)、
319
+ * Selection 收窄(`--experiment`/`--run`)、报告装载(`--report`)、查看器(`--out`/`--port`/`--open`)
320
+ * 不能被 exp 静默忽略(见 docs/feature/experiments/cli.md「用法错误」)。返回第一个被误用的
321
+ * flag 及其归属命令(用于报错),没有误用返回 undefined。
322
+ */
323
+ function firstViewerOnlyFlag(flags: Flags): { flag: string; command: string } | undefined {
324
+ const SHOW = "show";
325
+ const BOTH = "show / view";
326
+ const VIEW = "view";
327
+ if (flags.eval) return { flag: "--eval", command: SHOW };
328
+ if (flags.execution) return { flag: "--execution", command: SHOW };
329
+ if (flags.timing !== undefined) return { flag: "--timing", command: SHOW };
330
+ if (flags.diff || flags.diffPath !== undefined) return { flag: "--diff", command: SHOW };
331
+ if (flags.history) return { flag: "--history", command: SHOW };
332
+ if (flags.experiment !== undefined) return { flag: "--experiment", command: BOTH };
333
+ if (flags.run !== undefined) return { flag: "--run", command: BOTH };
334
+ if (flags.report !== undefined) return { flag: "--report", command: BOTH };
335
+ if (flags.out !== undefined) return { flag: "--out", command: VIEW };
336
+ if (flags.port !== undefined) return { flag: "--port", command: VIEW };
337
+ if (flags.open !== undefined) return { flag: "--open", command: VIEW };
338
+ return undefined;
339
+ }
340
+
231
341
  /** 调度项的环境变量层(标志 > 环境变量 > config > 默认,见 docs/cli.md「配置优先级」)。 */
232
342
  function envNumber(name: string): number | undefined {
233
343
  const raw = process.env[name]?.trim();
@@ -357,6 +467,76 @@ function evalsFilterFromExperiment(
357
467
  return (id) => expFilter(id) && patternFilter(id);
358
468
  }
359
469
 
470
+ /**
471
+ * evals 过滤器的指纹(进 ExperimentRunInfo.evalFilterFingerprint,供「配置没变」判断):
472
+ * 数组按内容、函数按函数体哈希;CLI 追加的位置参数前缀一并计入。不存过滤器本身——
473
+ * 求值结果在 selectedEvalIds(见 runEvals)。
474
+ */
475
+ function fingerprintEvalsFilter(evals: DiscoveredExperiment["evals"], patterns: string[]): string {
476
+ const basis =
477
+ evals === undefined || evals === "*"
478
+ ? "*"
479
+ : Array.isArray(evals)
480
+ ? JSON.stringify([...evals].sort())
481
+ : evals.toString();
482
+ return createHash("sha256").update(JSON.stringify({ basis, patterns })).digest("hex").slice(0, 16);
483
+ }
484
+
485
+ /**
486
+ * run 结束后把 coordinator 累计的诊断折成 `RunCompletion`(见 docs/feature/experiments/cli.md
487
+ * 「运行完成状态不只看 verdict 计数」)。只读已经真实发生过的诊断,不额外发明信号:
488
+ * - `"interrupted"` 诊断只在 run.ts 判定为真·中断(Effect exit 真实标记中断,不是「signal 被
489
+ * abort 过」这种更弱的信号)时才会出现,见 `runner/run.ts` 的 `reportInterrupted()` 调用点。
490
+ * - `"budget-exhausted:<experimentId>"` 诊断的 `count` 就是该 experiment 因预算耗尽未派发的
491
+ * attempt 数(见 `runner/feedback/reducer.ts` 对 `budget-exhausted` 事件的注释),跨
492
+ * experiment 求和得到 `unstarted`。
493
+ * - `"reporter-error:<reporter>"` 诊断转成 `ReporterError[]`;`required` 字段来自事件自带的
494
+ * `data.required`,直接反映这个 reporter 注册时的真实 required/best-effort 分类(见上面
495
+ * 构造 `reporters: ReporterRegistration[]` 的地方——artifacts / --json / --junit 恒
496
+ * `required: true`,`config.reporters` 恒 `false`),不是一个统一写死的占位值。
497
+ * - `earlyExitUnstarted` 从反馈状态的 attempt:early-exit 计数派生(减去 fail-fast 的那部分——
498
+ * 那是「未完整覆盖」,进 unstarted,不是「省下的重复验证」)。
499
+ */
500
+ function assembleRunCompletion(state: RunFeedbackState): RunCompletion {
501
+ let unstarted = 0;
502
+ let failFastSkipped = 0;
503
+ let interrupted = false;
504
+ const reporterErrors: ReporterError[] = [];
505
+ for (const d of state.diagnostics) {
506
+ if (d.key === "interrupted") {
507
+ interrupted = true;
508
+ } else if (d.key.startsWith("budget-exhausted:")) {
509
+ unstarted += d.count;
510
+ } else if (d.key.startsWith("fail-fast:")) {
511
+ // run 级 fail-fast 造成的未派发同样计入 unstarted(结论落 incomplete,见
512
+ // docs/feature/experiments/architecture.md「Completion 与退出」)。
513
+ unstarted += d.count;
514
+ failFastSkipped += d.count;
515
+ } else if (d.key.startsWith("reporter-error:")) {
516
+ // required 决定这条错误是否写进 RunCompletion.reporterErrors 并让 completion 非 complete
517
+ // (见 docs/cli.md「required reporter」);best-effort reporter 的失败只保留为 diagnostic。
518
+ if (d.data?.required !== true) continue;
519
+ reporterErrors.push({
520
+ reporter: typeof d.data?.reporter === "string" ? d.data.reporter : d.key.slice("reporter-error:".length),
521
+ required: true,
522
+ message: d.message,
523
+ });
524
+ }
525
+ }
526
+ // 中断造成的未派发(仍在 queued 的 attempt)同样计入 unstarted(见 docs/feature/experiments/
527
+ // architecture.md「Completion 与退出」:budget 耗尽、fail-fast 或中断造成的未派发都不伪装成全绿)。
528
+ if (interrupted) unstarted += state.queued;
529
+ // attempt:early-exit 计数含 fail-fast 的未派发(反馈层同一事件驱动计数守恒);
530
+ // 「省下的重复验证」= 总数减去 fail-fast 那部分。
531
+ const earlyExitUnstarted = Math.max(0, state.earlyExitSkipped - failFastSkipped);
532
+ const status: CompletionStatus = interrupted
533
+ ? "interrupted"
534
+ : unstarted > 0 || reporterErrors.length > 0
535
+ ? "incomplete"
536
+ : "complete";
537
+ return { status, unstarted, earlyExitUnstarted, reporterErrors };
538
+ }
539
+
360
540
  /** package.json 的 version 字段;-v/--version 直接回显这个号。 */
361
541
  async function packageVersion(): Promise<string> {
362
542
  const raw = await readFile(new URL("../package.json", import.meta.url), "utf-8");
@@ -394,7 +574,7 @@ async function main(): Promise<void> {
394
574
  ...(flags.report !== undefined ? { report: { path: flags.report, cwd } } : {}),
395
575
  };
396
576
  if (flags.out) {
397
- const out = await buildView({ input: viewInput.input, out: flags.out, scan }).catch(exitOnViewUserError);
577
+ const out = await buildView({ input: viewInput.input, out: flags.out, allowSensitiveArtifacts: flags.allowSensitiveArtifacts, scan }).catch(exitOnViewUserError);
398
578
  process.stdout.write(t("cli.view.exportedDir", { out }));
399
579
  process.exit(0);
400
580
  }
@@ -410,11 +590,26 @@ async function main(): Promise<void> {
410
590
  await new Promise(() => {});
411
591
  }
412
592
 
593
+ if (command === "sandbox") {
594
+ // sandbox 命令组不读 niceeval.config.ts、不发现 eval:只操作留存注册表与 provider 的
595
+ // detached 能力(见 docs/feature/sandbox/cli.md)。
596
+ const { runSandboxCommand } = await import("./sandbox/cli-commands.ts");
597
+ const code = await runSandboxCommand(cwd, positionals, {
598
+ all: flags.all,
599
+ window: flags.window,
600
+ path: flags.sandboxPath,
601
+ leaveRunning: flags.leaveRunning,
602
+ run: flags.run,
603
+ });
604
+ process.exit(code);
605
+ }
606
+
413
607
  if (command === "show") {
414
608
  // show 不依赖 niceeval.config.ts:读的是 .niceeval/(或 --run 指定的某个快照目录)的落盘结果。
415
609
  const code = await runShow(cwd, positionals, {
416
610
  eval: flags.eval,
417
611
  execution: flags.execution,
612
+ timing: flags.timing,
418
613
  diff: flags.diff,
419
614
  diffPath: flags.diffPath,
420
615
  history: flags.history,
@@ -453,15 +648,26 @@ async function main(): Promise<void> {
453
648
  }
454
649
 
455
650
  const agentRuns: AgentRun[] = [];
651
+ let experimentSelection = t("cli.all");
652
+ let availableExperimentGroups = t("cli.none");
456
653
 
457
654
  if (command === "exp") {
458
655
  if (flags.agent || flags.model) {
459
656
  process.stderr.write(t("cli.exp.agentModelFlagUnsupported"));
460
657
  process.exit(1);
461
658
  }
659
+ const viewerFlag = firstViewerOnlyFlag(flags);
660
+ if (viewerFlag) {
661
+ process.stderr.write(t("cli.exp.viewerFlagUnsupported", { flag: viewerFlag.flag, command: viewerFlag.command }));
662
+ process.exit(1);
663
+ }
462
664
  const experiments = await discoverExperiments(cwd);
463
665
  const expArg = positionals[0];
464
666
  const extraPatterns = positionals.slice(1);
667
+ experimentSelection = positionals.join(" ") || t("cli.all");
668
+ availableExperimentGroups = [...new Set(experiments.map((experiment) => experiment.group || experiment.id))]
669
+ .sort()
670
+ .join(", ") || t("cli.none");
465
671
  const selected = expArg
466
672
  ? experiments.filter((e) => e.group === expArg || e.id === expArg || e.id.startsWith(expArg + "/"))
467
673
  : experiments;
@@ -470,8 +676,21 @@ async function main(): Promise<void> {
470
676
  arg: expArg ?? t("cli.all"),
471
677
  experiments: experiments.map((e) => e.id).join(", ") || t("cli.none"),
472
678
  }));
679
+ // show / view 是顶层命令。只有同名 experiment 确实不存在时才纠错,不能抢占合法 id。
680
+ if (expArg === "show" || expArg === "view") {
681
+ process.stderr.write(t("cli.experiment.viewerCommandHint", {
682
+ command: expArg,
683
+ args: extraPatterns.length > 0 ? ` ${extraPatterns.join(" ")}` : "",
684
+ }));
685
+ }
473
686
  process.exit(1);
474
687
  }
688
+ // 残留提醒:注册表里还有上次留下的沙箱时打一行(不阻塞、不清理)。
689
+ {
690
+ const { keptSandboxReminder } = await import("./sandbox/cli-commands.ts");
691
+ const reminder = await keptSandboxReminder(cwd).catch(() => undefined);
692
+ if (reminder) process.stderr.write(reminder);
693
+ }
475
694
  for (const exp of selected) {
476
695
  // 一个实验 = 一个配置(单 model)。跨模型对比写多个实验文件,各钉一个 model。
477
696
  agentRuns.push({
@@ -486,6 +705,8 @@ async function main(): Promise<void> {
486
705
  budget: flags.budget ?? envNumber("NICEEVAL_BUDGET") ?? exp.budget,
487
706
  evalFilter: evalsFilterFromExperiment(exp.evals, extraPatterns),
488
707
  experimentId: exp.id,
708
+ description: exp.description,
709
+ evalFilterFingerprint: fingerprintEvalsFilter(exp.evals, extraPatterns),
489
710
  strict: flags.strict,
490
711
  // 实验级并发上限:随 AgentRun 进调度器按实验单独限流(runner 两级信号量),
491
712
  // 不再取所有选中实验的最小值钳全局——那会让一个串行实验拖慢整批基线。
@@ -513,69 +734,119 @@ async function main(): Promise<void> {
513
734
  process.exit(1);
514
735
  }
515
736
 
737
+ // profile 只改变反馈,不改变选择/调度/判定;显式值覆盖 auto 检测(见 resolveOutputProfile)。
738
+ // --dry 和真正开跑共用同一个已解析 profile。
739
+ const outputProfile = resolveOutputProfile({
740
+ explicit: flags.output,
741
+ isTTY: process.stderr.isTTY === true,
742
+ env: process.env,
743
+ });
744
+
745
+ // matchedByRun[i] 对应 agentRuns[i] 匹配到的 eval 集合;--dry 预览与真正开跑时的
746
+ // RunFeedbackPlan(总量、去重 eval 数)共用同一份计算,不重复过滤一遍。
747
+ const matchedByRun = agentRuns.map((run) => evals.filter((e) => run.evalFilter(e.id)));
748
+ const totalRuns = agentRuns.reduce((sum, run, i) => sum + matchedByRun[i]!.length * run.runs, 0);
749
+ const uniqueEvalIds = new Set(matchedByRun.flat().map((e) => e.id));
750
+
751
+ if (totalRuns === 0) {
752
+ process.stderr.write(t("cli.experiment.noEvalsSelected", {
753
+ selection: experimentSelection,
754
+ experiments: availableExperimentGroups,
755
+ }));
756
+ process.exit(1);
757
+ }
758
+
516
759
  if (flags.dry) {
517
- process.stdout.write(t("cli.dry.header", { evals: evals.length, configs: agentRuns.length }));
518
- for (const run of agentRuns) {
519
- const matched = evals.filter((e) => run.evalFilter(e.id));
520
- const who = run.model ? `${run.agent.name}/${run.model}` : run.agent.name;
521
- process.stdout.write(t("cli.dry.row", {
522
- who,
523
- experiment: run.experimentId ? ` (exp ${run.experimentId})` : "",
524
- evals: matched.map((e) => e.id).join(", ") || t("cli.dry.noMatches"),
525
- runs: run.runs,
526
- }));
760
+ // --dry 只按所选 profile 打印计划,不运行、不落盘 —— 三种 profile 各自的展示逻辑
761
+ // 都在 runner/feedback/{human,agent,ci}.ts 里,这里只负责拼数据、选函数、写流、退出。
762
+ if (outputProfile === "agent") {
763
+ const rows: AgentPlanRow[] = [];
764
+ for (let i = 0; i < agentRuns.length; i++) {
765
+ const run = agentRuns[i]!;
766
+ const label = run.experimentId ?? (run.model ? `${run.agent.name}/${run.model}` : run.agent.name);
767
+ for (const e of matchedByRun[i]!) rows.push({ label, evalId: e.id });
768
+ }
769
+ process.stdout.write(
770
+ renderAgentPlanEnvelope({
771
+ total: totalRuns,
772
+ evals: uniqueEvalIds.size,
773
+ configs: agentRuns.length,
774
+ runs: Math.max(1, ...agentRuns.map((r) => r.runs)),
775
+ rows,
776
+ }) + "\n",
777
+ );
778
+ } else if (outputProfile === "ci") {
779
+ process.stdout.write(
780
+ renderCiDryPlan({
781
+ total: totalRuns,
782
+ evals: uniqueEvalIds.size,
783
+ configs: agentRuns.length,
784
+ rows: agentRuns.map((run, i) => ({
785
+ experimentId: run.experimentId,
786
+ who: run.model ? `${run.agent.name}/${run.model}` : run.agent.name,
787
+ evalIds: matchedByRun[i]!.map((e) => e.id),
788
+ runs: run.runs,
789
+ })),
790
+ }),
791
+ );
792
+ } else {
793
+ process.stdout.write(
794
+ renderHumanDryPlan({
795
+ evals: evals.length,
796
+ configs: agentRuns.length,
797
+ rows: agentRuns.map((run, i) => ({
798
+ who: run.model ? `${run.agent.name}/${run.model}` : run.agent.name,
799
+ experimentSuffix: run.experimentId ? ` (exp ${run.experimentId})` : "",
800
+ evalIds: matchedByRun[i]!.map((e) => e.id),
801
+ runs: run.runs,
802
+ })),
803
+ }),
804
+ );
527
805
  }
528
806
  process.exit(0);
529
807
  }
530
808
 
531
- // 提前算好(而不是等 runEvals 内部算):live 表格要在第一帧就知道哪些行会被携入
532
- // (carry),直接渲染成已完成,不然会显示"waiting for a slot"直到进程退出——它们
533
- // 永远等不到 eval:start,run.ts 压根不会为携入的 (experimentId, evalId) 派发 attempt。
534
- // 两处必须共用同一份 planCarry() 判断,否则各自算一遍,一旦判断不一致,live 表格
535
- // 显示的"携入"和 run.ts 实际调度的"携入"就会对不上。
809
+ // 提前算好携入计划:coordinator 的 plan 事件与 runEvals 内部
810
+ // 实际调度必须共用同一份 planCarry() 判断,否则两边各自算一遍,一旦不一致,dashboard/
811
+ // envelope 展示的"携入"就会和 run.ts 真实调度的"携入"对不上( memory
812
+ // live-carry-row-shows-waiting-forever)
536
813
  const priorResults = flags.force ? undefined : await loadLatestResultsPerEval(join(cwd, ".niceeval"));
537
814
  const carryPlan = priorResults?.length ? await planCarry(evals, agentRuns, priorResults) : undefined;
538
- const carriedVerdictByKey = new Map<string, string>();
539
- for (const r of carryPlan?.carriedResults ?? []) {
540
- if (r.experimentId) carriedVerdictByKey.set(`${r.experimentId}|${r.id}`, r.verdict);
541
- }
815
+ const reusedFailures = (carryPlan?.carriedResults ?? [])
816
+ .map(failureDetailFromResult)
817
+ .filter((failure) => failure !== undefined);
542
818
 
543
- const reporters: Reporter[] = [];
544
- let onProgress: ((evalId: string, who: string, msg: string) => void) | undefined;
545
-
546
- if (!flags.quiet) {
547
- if (process.stderr.isTTY) {
548
- // TTY 模式:用 live display 替换 Console reporter,把 attempt log 路由到状态表行尾
549
- const liveRows: LiveRow[] = [];
550
- for (const agentRun of agentRuns) {
551
- // who 必须与 attempt.ts 的进度上报同源(runWho):曾用 agent/model,同 agent 同 model
552
- // 的实验变体(xxx 与 xxx--agents-md)会被折叠成一行,0/2 看起来像同一 eval 跑两次。
553
- const who = runWho({ agentName: agentRun.agent.name, model: agentRun.model, experimentId: agentRun.experimentId });
554
- const matched = evals.filter((e) => agentRun.evalFilter(e.id));
555
- for (const evalDef of matched) {
556
- const carriedVerdict = agentRun.experimentId
557
- ? carriedVerdictByKey.get(`${agentRun.experimentId}|${evalDef.id}`)
558
- : undefined;
559
- liveRows.push({ evalId: evalDef.id, who, total: agentRun.runs, carriedVerdict });
560
- }
561
- }
562
- const totalAttempts = liveRows.reduce((s, r) => s + r.total, 0);
563
- const live = LiveReporter(liveRows, totalAttempts);
564
- reporters.push(live);
565
- onProgress = (evalId, who, msg) => live.progress(evalId, who, msg);
566
- } else {
567
- reporters.push(ConsoleReporter());
568
- }
569
- } else {
570
- // --quiet:进度流照旧直写 stderr,结果流换成最小报告器 —— errored / failed 各补一行
571
- // stderr,passed / skipped 静默。没有它,attempt 出执行错时控制台会全程无声。
572
- reporters.push(QuietReporter());
573
- }
574
- const artifacts = ArtifactsReporter();
575
- reporters.push( artifacts);
576
- if (flags.junit) reporters.push(JUnit(flags.junit));
577
- if (flags.json) reporters.push(Json(flags.json));
578
- reporters.push(...(config.reporters ?? []));
819
+ // 无全局默认:并发上限由 sandbox provider 的推荐值决定(多个 agentRun 各有 sandbox 时取
820
+ // 最小值,最保守的 provider 决定上限)。同一个值既进 RunFeedbackPlan.shape,也传给 runEvals——
821
+ // 两处必须是同一个数字,dashboard 展示的并发上限不能和真实调度的并发上限对不上。
822
+ const sandboxRecs = agentRuns.map((r) => sandboxRecommendedConcurrency(r.sandbox));
823
+ const sandboxDefaultConcurrency = sandboxRecs.length > 0 ? Math.min(...sandboxRecs) : 10;
824
+ const maxConcurrency =
825
+ flags.maxConcurrency ??
826
+ envNumber("NICEEVAL_MAX_CONCURRENCY") ??
827
+ config.maxConcurrency ??
828
+ sandboxDefaultConcurrency;
829
+
830
+ const plan: RunFeedbackPlan = {
831
+ shape: { evals: uniqueEvalIds.size, configs: agentRuns.length, totalRuns, maxConcurrency },
832
+ reused: carryPlan?.carriedResults.length ?? 0,
833
+ reusedFailures,
834
+ };
835
+
836
+ // 一个 run 内只有一个终端协调者(见 docs/feature/experiments/cli.md「输出流和落盘节奏」):
837
+ // 三种 profile 各自的展示逻辑全部在 renderer 里,这里只按解析出的 profile 选一个构造好、
838
+ // 交给 coordinator。run:start 前(coordinator.start(plan) 之前)的一切都还没有活跃 sink,
839
+ // 出错走 bootstrap stderr;之后所有诊断都经它。
840
+ const io = createNodeFeedbackIO();
841
+ const commandLabel = ["niceeval", command, ...positionals].join(" ").trim();
842
+ const renderer =
843
+ outputProfile === "human"
844
+ ? createHumanRenderer({ io, command: commandLabel })
845
+ : outputProfile === "agent"
846
+ ? createAgentRenderer({ io })
847
+ : createCiRenderer({ io });
848
+ const coordinator = createFeedbackCoordinator({ profile: outputProfile, renderer, io });
849
+ coordinator.start(plan);
579
850
 
580
851
  // Ctrl+C / kill 的三级响应,核心目标:任何情况下都不留下孤儿沙箱。
581
852
  // 1 次:abort controller → runEvals 把它喂给 Effect signal → 各 attempt 的 Scope 跑 release
@@ -586,28 +857,29 @@ async function main(): Promise<void> {
586
857
  // 3 次:真不耐烦了,硬退(此时多半已无可清理的)。
587
858
  const ctrl = new AbortController();
588
859
  let signalCount = 0;
589
- // 兜底强清 + 退出:只跑一次,带超时(stopAllSandboxes 内每个 stop 各自有超时)
860
+ // 兜底强清 + 退出:只跑一次,带超时(stopAllSandboxes 内每个 stop 各自有超时)。先停 dashboard
861
+ // 的 tick/动态区域(coordinator.stopDynamic()),避免硬退时终端卡在半帧 ANSI 状态。
590
862
  let forcing = false;
591
863
  const forceCleanupAndExit = (code: number) => {
592
864
  if (forcing) return;
593
865
  forcing = true;
594
- void stopAllSandboxes().finally(() => process.exit(code));
866
+ void Promise.all([coordinator.stopDynamic(), stopAllSandboxes()]).finally(() => process.exit(code));
595
867
  };
596
868
  for (const sig of ["SIGINT", "SIGTERM"] as const) {
597
869
  process.on(sig, () => {
598
870
  signalCount += 1;
599
871
  if (signalCount === 1) {
600
- process.stderr.write(t("cli.interruptCleanup"));
872
+ reportActivity(t("cli.interruptCleanup").trimEnd());
601
873
  ctrl.abort();
602
874
  // 看门狗:graceful 清理 12s 还没让进程自己收口,就强清兜底。
603
875
  setTimeout(() => {
604
876
  if (liveSandboxCount() > 0) {
605
- process.stderr.write(t("cli.fallbackCleanupTimeout"));
877
+ reportActivity(t("cli.fallbackCleanupTimeout").trimEnd());
606
878
  forceCleanupAndExit(130);
607
879
  }
608
880
  }, 12_000).unref();
609
881
  } else if (signalCount === 2) {
610
- process.stderr.write(t("cli.forceCleanupExit"));
882
+ reportActivity(t("cli.forceCleanupExit").trimEnd());
611
883
  forceCleanupAndExit(130);
612
884
  } else {
613
885
  process.exit(130); // 第三次:硬退
@@ -615,47 +887,78 @@ async function main(): Promise<void> {
615
887
  });
616
888
  }
617
889
 
618
- // 无全局默认:并发上限由 sandbox provider 的推荐值决定。
619
- // 多个 agentRun 各有 sandbox 时取最小值(最保守的 provider 决定上限)。
620
- const sandboxRecs = agentRuns.map((r) => sandboxRecommendedConcurrency(r.sandbox));
621
- const sandboxDefaultConcurrency = sandboxRecs.length > 0 ? Math.min(...sandboxRecs) : 10;
622
-
623
- const summary = await runEvals({
624
- config,
625
- evals,
626
- agentRuns,
627
- reporters,
628
- maxConcurrency:
629
- flags.maxConcurrency ??
630
- envNumber("NICEEVAL_MAX_CONCURRENCY") ??
631
- config.maxConcurrency ??
632
- sandboxDefaultConcurrency,
633
- signal: ctrl.signal,
634
- onProgress,
635
- priorResults,
636
- carryPlan,
890
+ // reporter 只剩正交的机器/artifact 出口:human/agent/ci 的展示完全由上面的 coordinator +
891
+ // renderer 负责,不再有 Console/Live/Quiet 这类兼职当 reporter 的展示层( docs
892
+ // 「CLI 只负责解析 profile、构造 coordinator/reporters、运行和退出」)。每个 reporter 在这里
893
+ // 按来源分类 required/best-effort(见 `ReporterRegistration` 的字段注释):默认落盘的
894
+ // artifacts、显式指定的 --json/--junit 是 agent/CI 读结果的唯一入口,写失败必须让
895
+ // completion/退出码判红;用户 `config.reporters` 只是补充观测,失败只折成一条 diagnostic,
896
+ // 不影响 completion。
897
+ const reporters: ReporterRegistration[] = [];
898
+ const artifacts = ArtifactsReporter();
899
+ reporters.push({ reporter: artifacts, name: "artifacts", required: true });
900
+ if (flags.junit) reporters.push({ reporter: JUnit(flags.junit), name: "junit", required: true, target: flags.junit });
901
+ if (flags.json) reporters.push({ reporter: Json(flags.json), name: "json", required: true, target: flags.json });
902
+ (config.reporters ?? []).forEach((reporter, i) => {
903
+ reporters.push({ reporter, name: `config-reporter-${i}`, required: false });
637
904
  });
638
905
 
906
+ let summary: RunSummary;
907
+ try {
908
+ summary = await runEvals({
909
+ config,
910
+ evals,
911
+ agentRuns,
912
+ reporters,
913
+ maxConcurrency,
914
+ signal: ctrl.signal,
915
+ priorResults,
916
+ carryPlan,
917
+ keepSandbox: flags.keepSandbox,
918
+ niceevalRoot: resolvePath(cwd, ".niceeval"),
919
+ });
920
+ } catch (e) {
921
+ // 真崩溃前先撤下 dashboard,不让半帧 ANSI 状态和下面 main().catch 打印的错误交织。
922
+ await coordinator.stopDynamic();
923
+ throw e;
924
+ }
925
+
639
926
  // 正常返回(含被中断后走部分汇总)后再兜一刀:Scope finalizer 没停掉的残留沙箱在这里强清。
640
927
  // 跑顺利时登记表已空,是 no-op。
641
928
  await stopAllSandboxes();
642
929
 
643
- // agent 反馈闭环的入口:跑完直接给出每个已创建快照的目录,agent 读 snapshot.json 与各
644
- // attempt result.json / artifact(events/trace/diff),不必解析人类向的流式输出。
645
- // --quiet 下也输出。相对 cwd 的路径更友好;结果落在 cwd 外时(relative 路径以 .. 开头)
646
- // 原样打印绝对路径。
647
- for (const { dir } of artifacts.outputDirs()) {
930
+ // completion 要先算好,--json/--junit 是否"这次真的写出"才有依据(见下)。
931
+ const completion = assembleRunCompletion(coordinator.state);
932
+
933
+ // --json/--junit 是正交机器出口,只在这次运行真的写出对应文件时才把路径交给 coordinator
934
+ // (它转发给 ci renderer 打印独立的 json=/junit= 行,见 docs「CI 怎么用」)。判据是
935
+ // completion.reporterErrors 里有没有这次 required reporter("json"/"junit")的失败记录——
936
+ // 不能用 existsSync 探测磁盘:atomicWriteFile(json.ts)失败时原地保留上一次运行遗留的旧文件,
937
+ // existsSync 只会看到"文件存在"就误判成这次写成功,把上一轮的陈旧内容当成本次结果打印出去。
938
+ const jsonPath = flags.json && !completion.reporterErrors.some((e) => e.reporter === "json") ? flags.json : undefined;
939
+ const junitPath =
940
+ flags.junit && !completion.reporterErrors.some((e) => e.reporter === "junit") ? flags.junit : undefined;
941
+
942
+ // agent 反馈闭环的入口:跑完直接给出每个已创建快照的目录,agent/ci 读 snapshot.json 与各
943
+ // attempt 的 result.json / artifact(events/trace/diff),不必解析人类向的流式输出。相对 cwd
944
+ // 的路径更友好;结果落在 cwd 外时(relative 路径以 .. 开头)原样打印绝对路径。打印本身由
945
+ // renderer 的 "saved" 处理完成,这里只负责把路径交给 coordinator。
946
+ const paths = artifacts.outputDirs().map(({ dir }) => {
648
947
  const rel = relative(cwd, dir);
649
- process.stdout.write(t("cli.resultsPath", { path: rel && !rel.startsWith("..") ? rel : dir }));
650
- }
948
+ return rel && !rel.startsWith("..") ? rel : dir;
949
+ });
651
950
 
652
- // 退出码按 eval 级判定,不按 attempt:summary.failed/errored 统计的是每次 attempt,
653
- // 被 runs+earlyExit 重试吸收的失败(先挂一次、后来过了)不该把进程判红——否则
654
- // 「runs 吸收单次抖动」在 CI 退出码这层永远不成立。折叠口径与报表/view 共用
655
- // foldEvalVerdict(任一轮通过 eval 通过),粒度 experimentId|eval id。
656
- const stats = evalLevelStats(summary.results, (r) => `${r.experimentId ?? ""}|${r.id}`);
657
- const failedExit = stats.failed > 0 || stats.errored > 0;
658
- process.exit(failedExit ? 1 : 0);
951
+ await coordinator.finish({ summary, completion, paths, json: jsonPath, junit: junitPath });
952
+
953
+ // 退出码统一走 CompletionStatus 驱动的语义(interrupted 130、incomplete/required reporter
954
+ // 失败1),不再只看 verdict 计数;三种 profile 共用同一套退出码,不是 ci 专属。failed/errored
955
+ // 先按 (experiment, eval) 折叠再喂给 computeCiExitCode——它只认 RunSummary 原始字段,不知道
956
+ // 「同一 eval 的重试轮不该重复计红」这条 eval 级判定规则(被 runs+earlyExit 重试吸收的失败,
957
+ // 先挂一次、后来过了,不该把进程判红,否则 CI 判定与 evalLevelStats 报表口径不一致;
958
+ // 见 memory 的 cli-exit-code-attempt-level-not-eval-level)。
959
+ const foldedStats = evalLevelStats(summary.results, (r) => `${r.experimentId ?? ""}|${r.id}`);
960
+ const exitCode = computeCiExitCode({ ...summary, failed: foldedStats.failed, errored: foldedStats.errored }, completion);
961
+ process.exit(exitCode);
659
962
  }
660
963
 
661
964
  main().catch(async (e) => {