@catheadowl/dsh-eval 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CatheadOwl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ ---
2
+ description: '@catheadowl/dsh-eval — dsh-native agent evaluation layer for plugin authors:behavior case 跑真实 headless dsh trace,review experiment 测 fresh model 能否理解插件输出'
3
+ ---
4
+
5
+ # @catheadowl/dsh-eval
6
+
7
+ **A dsh-native agent evaluation layer for plugin authors**: behavior cases run against real headless dsh traces, while review experiments test whether fresh models understand plugin outputs.
8
+
9
+ 它评测的是**装配后的 agent harness**(插件 + profile + patch + 工具注册表在真实 dsh headless 里接成的那张图),不是孤立函数;判定走 dsh 原生的 session trace 投影与 matcher(契约断言),不是 metric 分数。它不是通用 agent eval 平台(无 dashboard / dataset hosting / metric catalog,也不做 benchmark 排名),也不是 DeepEval / OpenAI Evals 的替代品——那些项目证明了这个问题空间成立,本包选择 dsh-native 的垂直解法。
10
+
11
+ > 文档以中文为主;深度契约在 [docs/](docs/README.md)(matchers / 边界契约 / review / 报告结构 / 宿主接线 / 已知问题)。
12
+
13
+ ## 为什么需要它
14
+
15
+ | 类型 | 问题 | 判定 | 执行 |
16
+ |---|---|---|---|
17
+ | 单元/shape test | 确定性字段和值是否正确 | 自动 | plugin 自己的 `node:test` |
18
+ | behavior real | 自然语言意图是否选到正确工具 | trace matcher | dsh + 真实模型 |
19
+ | behavior mock | 工具管线与写入 round-trip 是否稳定 | trace matcher + workspace inspect | dsh + 脚本化 mock LLM |
20
+ | comprehension review | 一个 fresh model 能否从输出理解含义和下一步 | 人工对照 rubric,多次收敛 | 抽象 review experiment + 可替换 executor |
21
+
22
+ dsh 插件的正确性来自「装配出的图是否真的把工具、steer、prompt、gate 接到一起」——这类问题插件自己的单测只能覆盖一部分;而「输出能否被理解」根本不是字符串回归。本包把这两层从手动试跑变成可复跑证据。
23
+
24
+ ```text
25
+ plugin-owned experiment shared framework
26
+ fixtures + prompt + rubric + observe ──► experiment/review.mjs
27
+ │ task
28
+
29
+ adapters/dsh/review.mjs ──► dsh headless
30
+
31
+ behavior *.eval.mjs ───────────────────► dsh behavior runner (trace + mock)
32
+ ```
33
+
34
+ - `src/experiment/` 是模型与 runtime 无关的试验设计层:blind review、实时观测、多次 reviewer 字节一致证据。它不 import dsh。
35
+ - `src/adapters/dsh/` 是落地层:把抽象任务交给隔离的 dsh headless。
36
+ - 你的 `eval/` 只保留领域 fixture、projection/observe、prompt、rubric 与 case,不复制 runner。
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ npm i -D @catheadowl/dsh-eval
42
+ ```
43
+
44
+ **Requirements**(接线细节与失败自诊断见 [docs/host-wiring.md](docs/host-wiring.md)):
45
+
46
+ - 一个已构建的 deepseek-harness 检出(`apps/cli/lib/bin.js`);
47
+ - 被测插件已装进某个 dsh profile;
48
+ - peer 依赖 `@deepseek-ai/dsh-llm` 需手工接线(npm 会自动装到不兼容的古董版,须替换为指向宿主检出的链接)。
49
+
50
+ ## Quickstart
51
+
52
+ `<plugin>/eval/behavior/mock/smoke.eval.mjs`:
53
+
54
+ ```js
55
+ import { firstTool, toolCalled, toolCallStep, textStep } from '@catheadowl/dsh-eval'
56
+
57
+ export default {
58
+ id: 'my-first-case',
59
+ mode: 'mock',
60
+ task: '把 guide.md 重命名为 intro.md',
61
+ async prepare(workspace) { /* 播种 fixture 文件 */ },
62
+ script: { steps: [toolCallStep('md_rename', { oldPath: 'guide.md', newPath: 'intro.md' }), textStep('done')] },
63
+ expect: [toolCalled('md_rename')],
64
+ }
65
+ ```
66
+
67
+ ```bash
68
+ dsh-eval run --mode mock eval/behavior/mock
69
+ dsh-review --dry-run eval/comprehension # review 层的免模型预演
70
+ ```
71
+
72
+ 真实运行用 `dsh-eval run --profile <p> --repo <harness 检出> <case 路径>`;全部 flags(`--mode/--keep-artifacts/--fail-on-skip/--format/--report`)见 [docs/report.md](docs/report.md)。real case 无凭证时 auto-skip(dsh 自己解析凭证),mock 与 dry-run 不需要任何凭证。
73
+
74
+ ## 规范目录
75
+
76
+ ```text
77
+ <plugin>/eval/
78
+ .gitignore # .runs/(无路径前缀)
79
+ README.md
80
+ behavior/ # 可选
81
+ real/*.eval.mjs
82
+ mock/*.eval.mjs
83
+ _fixtures/
84
+ comprehension/ # 可选
85
+ <name>.review.mjs
86
+ fixtures.json
87
+ prompt.md
88
+ rubric.md
89
+ ```
90
+
91
+ ## 统一配置 dsh-eval.config.mjs
92
+
93
+ 消费者包根放一份,两个 CLI 从工作目录向上查找,flags 永远覆盖 config:
94
+
95
+ ```js
96
+ export default {
97
+ profile: 'headless', // dsh profile
98
+ repo: '../../deepseek-harness', // 相对路径锚定 config 文件所在目录
99
+ mode: 'mock', // behavior CLI 的 --mode 默认(review 无此项)
100
+ failOnSkip: false, // behavior CI 门禁默认
101
+ report: 'eval-report.json', // --report 默认(锚定 config 目录)
102
+ disableRows: ['gates'], // case 默认禁用的插件行;case 级声明覆盖
103
+ // (显式 [] = 全启用,gate 交互 case 用)
104
+ }
105
+ ```
106
+
107
+ 未知 key 直接报错(拼写错误不静默退化)。`disableRows` 的语义与 turn-close 门禁边界契约见 [docs/disablerows.md](docs/disablerows.md)。
108
+
109
+ ## Docs
110
+
111
+ | 文档 | 主题 |
112
+ |---|---|
113
+ | [host-wiring](docs/host-wiring.md) | peer 接线(含 npm 古董 peer 坑)、构建 CLI、profile、凭证、spawn 要求 |
114
+ | [review](docs/review.md) | comprehension review:实验定义、sterile profile、产物、六条固化规则 |
115
+ | [matchers](docs/matchers.md) | trace matcher 与 mock helper 全集(工具面 / 文本面 / 模型可见面) |
116
+ | [disablerows](docs/disablerows.md) | `disableRows` 与 turn-close 门禁边界契约 |
117
+ | [intent-cases](docs/intent-cases.md) | real 意图 case 规约:何时写、断言面、守卫、CI 语义 |
118
+ | [report](docs/report.md) | 机器可读报告(`--format json` / `--report`)结构 |
119
+ | [known-issues](docs/known-issues.md) | 已知问题与规避(如 staged home 的 REQUEST_EXTENSION) |
120
+
121
+ ## 运行保障
122
+
123
+ runner 用 `try/finally` 保证临时目录与链接在任何路径(`prepare` 抛错、mock 校验失败、spawn 错误)都被清理,不污染真实 profile store。behavior 与 review CLI 共享目录扫描(跳过 `.runs` 与 `node_modules`);behavior CLI 在加载期做 case shape 校验与跨文件重复 id 检测,尽早失败。
124
+
125
+ License: MIT。框架自身的测试与发布自检由仓库 CI 承接,不随包发布。
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-eval — the plugin agent-eval case executor.
4
+ *
5
+ * Usage:
6
+ * dsh-eval run --profile <name> --repo <deepseek-harness dir>
7
+ * [--mode real|mock|all] [--keep-artifacts] [--fail-on-skip]
8
+ * [--format text|json] [--report <file>]
9
+ * <case paths...>
10
+ *
11
+ * A case path is a `*.eval.mjs` file or a directory scanned recursively for
12
+ * them. Each file default-exports one case object (or an array of them):
13
+ * `{ id, task, mode?: 'real'|'mock', expect: Matcher[], script?, persona?,
14
+ * prepare?, timeoutMs? }`. Real cases skip when DEEPSEEK_API_KEY is absent;
15
+ * the exit code is 1 when any run fails. Failures keep their artifacts under
16
+ * `<case file dir>/.runs/<case id>/`.
17
+ *
18
+ * Output formats (EVAL-007):
19
+ * - `--format text` (default): unchanged human output on stdout/stderr.
20
+ * - `--format json`: all progress and failure chatter moves to stderr;
21
+ * stdout receives exactly one JSON report object (see src/report.mjs).
22
+ * - `--report <file>`: additionally write that report object to a file,
23
+ * in either format — the aggregation/CI consumption path.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
30
+ import { runEvalCase } from '../src/runner.mjs'
31
+ import { discoverFiles, validateEvalCase, detectDuplicateIds } from '../src/discovery.mjs'
32
+ import { createCaseRecord, buildRunReport, reportExitCode, mockDeterminismHint } from '../src/report.mjs'
33
+ import { loadEvalConfig } from '../src/config.mjs'
34
+ import { resolveDshCliChain } from '../src/cli.mjs'
35
+
36
+ function usage(error) {
37
+ const text = [
38
+ 'usage: dsh-eval run [--profile <name>] [--repo <deepseek-harness>] [--mode real|mock|all] [--keep-artifacts] [--fail-on-skip] [--format text|json] [--report <file>] <case paths...>',
39
+ ' --profile/--repo/--mode/--fail-on-skip/--report may come from a dsh-eval.config.mjs found upward from cwd; flags override it.',
40
+ ].join('\n')
41
+ if (error === undefined) {
42
+ process.stdout.write(`${text}\n`)
43
+ process.exit(0)
44
+ }
45
+ process.stderr.write(`${error}\n${text}\n`)
46
+ process.exit(2)
47
+ }
48
+
49
+ /** Parse argv: known flags, then case paths. Profile/repo/mode/failOnSkip
50
+ * may come from a `dsh-eval.config.mjs` instead of flags (flags win);
51
+ * required-ness is checked after config merging, not here. */
52
+ function parseArgs(argv) {
53
+ const options = {
54
+ profile: undefined, repo: undefined, mode: undefined,
55
+ keepArtifacts: false, failOnSkip: undefined, format: 'text', report: undefined,
56
+ }
57
+ const paths = []
58
+ for (let i = 0; i < argv.length; i += 1) {
59
+ const arg = argv[i]
60
+ if (arg === 'run') continue
61
+ if (arg === '--profile') { options.profile = argv[++i]; continue }
62
+ if (arg === '--repo') { options.repo = argv[++i]; continue }
63
+ if (arg === '--mode') { options.mode = argv[++i]; continue }
64
+ if (arg === '--keep-artifacts') { options.keepArtifacts = true; continue }
65
+ if (arg === '--fail-on-skip') { options.failOnSkip = true; continue }
66
+ if (arg === '--format') { options.format = argv[++i]; continue }
67
+ if (arg === '--report') { options.report = argv[++i]; continue }
68
+ if (arg === '-h' || arg === '--help') usage()
69
+ paths.push(arg)
70
+ }
71
+ if (!['real', 'mock', 'all'].includes(options.mode ?? 'all')) usage(`error: --mode must be real, mock, or all (got '${options.mode}')`)
72
+ if (!['text', 'json'].includes(options.format)) usage(`error: --format must be text or json (got '${options.format}')`)
73
+ if (paths.length === 0) usage('error: at least one case file or directory is required')
74
+ return { options, paths }
75
+ }
76
+
77
+ /**
78
+ * Line output that respects the format: in `json` mode stdout is reserved
79
+ * for the single report object, so progress lines go to stderr instead.
80
+ */
81
+ function say(line) {
82
+ if (jsonFormat) process.stderr.write(`${line}\n`)
83
+ else process.stdout.write(`${line}\n`)
84
+ }
85
+
86
+ /** Recursively collect `*.eval.mjs` files from one file or directory path. */
87
+ function discoverCaseFiles(path) {
88
+ return discoverFiles(path, '.eval.mjs')
89
+ }
90
+
91
+ /** Import one case file, validate shape, and normalize to a case array. */
92
+ async function loadCases(file) {
93
+ const module = await import(pathToFileURL(file).href)
94
+ const exported = module.default
95
+ const list = Array.isArray(exported) ? exported : [exported]
96
+ for (const evalCase of list) {
97
+ validateEvalCase(evalCase, file)
98
+ }
99
+ return list.map(evalCase => ({ ...evalCase, __file: file }))
100
+ }
101
+
102
+ /**
103
+ * Whether a model credential is available to a real run: the process
104
+ * environment, or the managed `$DSH_HOME/.credentials.yaml` document that
105
+ * `dsh-credentials-local` resolves per request. The key's VALUE is never
106
+ * read here — presence is the gate.
107
+ */
108
+ function credentialAvailable() {
109
+ if (process.env.DEEPSEEK_API_KEY !== undefined) return true
110
+ const home = (process.env.DSH_HOME ?? '').trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
111
+ return existsSync(join(home, '.credentials.yaml'))
112
+ }
113
+
114
+ /** Why a case is skipped, or undefined when it should run. */
115
+ function skipReason(evalCase, modeFilter) {
116
+ const mode = evalCase.mode ?? 'real'
117
+ if (modeFilter !== 'all' && mode !== modeFilter) return `--mode ${modeFilter}`
118
+ if (mode === 'real' && !credentialAvailable()) {
119
+ return 'no credential (DEEPSEEK_API_KEY unset and no $DSH_HOME/.credentials.yaml)'
120
+ }
121
+ return undefined
122
+ }
123
+
124
+ /**
125
+ * Persist one run's post-mortem artifacts under `.runs/<case id>/` next to
126
+ * the case file: the in-memory streams/trace plus the raw session logs
127
+ * captured before the run dir cleanup.
128
+ */
129
+ function writeArtifacts(evalCase, result, mode) {
130
+ const artifactsDir = join(dirname(evalCase.__file), '.runs', evalCase.id)
131
+ try {
132
+ mkdirSync(artifactsDir, { recursive: true })
133
+ writeFileSync(join(artifactsDir, 'stdout.txt'), result.stdout)
134
+ writeFileSync(join(artifactsDir, 'stderr.txt'), result.stderr)
135
+ writeFileSync(join(artifactsDir, 'trace.json'), JSON.stringify({
136
+ caseId: evalCase.id, mode, task: evalCase.task,
137
+ exitCode: result.exitCode, timedOut: result.timedOut, trace: result.trace,
138
+ }, undefined, 2))
139
+ result.sessionLogs.forEach((text, index) => {
140
+ writeFileSync(join(artifactsDir, `session-${index}.jsonl`), text)
141
+ })
142
+ } catch { /* artifact persistence is best-effort */ }
143
+ return artifactsDir
144
+ }
145
+
146
+ const startedAt = new Date().toISOString()
147
+ const { options, paths } = parseArgs(process.argv.slice(2))
148
+ const jsonFormat = options.format === 'json'
149
+
150
+ // Config merge (EVAL-008): a `dsh-eval.config.mjs` reachable from cwd
151
+ // supplies defaults; explicit flags always win. Required-ness is only
152
+ // decided after the merge, so config-only invocations work.
153
+ const { config } = await loadEvalConfig(process.cwd())
154
+ const profile = options.profile ?? config.profile
155
+ const modeFilter = options.mode ?? config.mode ?? 'all'
156
+ const failOnSkip = options.failOnSkip ?? config.failOnSkip ?? false
157
+ if (profile === undefined) usage('error: --profile <name> is required (or set profile in dsh-eval.config.mjs)')
158
+ // CLI resolution (C6, spec host-checkout-resolution): `--repo` flag >
159
+ // resolution layer (node_modules/@deepseek-ai/dsh) > config repo key (legacy).
160
+ // Committed files carry no real host-checkout path.
161
+ const { cli: cliPath, repo: repoDir, source: cliSource } = resolveDshCliChain({
162
+ repoFlag: options.repo,
163
+ configRepo: config.repo,
164
+ })
165
+ const reportRepo = repoDir ?? cliPath
166
+
167
+ const files = paths.flatMap(path => {
168
+ const absolute = resolve(path)
169
+ if (!existsSync(absolute)) usage(`error: no such case path: ${path}`)
170
+ return discoverCaseFiles(absolute)
171
+ })
172
+ if (files.length === 0) usage('error: no *.eval.mjs case files found')
173
+
174
+ const records = []
175
+ const seenIds = new Map()
176
+
177
+ for (const file of files.sort()) {
178
+ let cases
179
+ try {
180
+ cases = await loadCases(file)
181
+ } catch (error) {
182
+ records.push(createCaseRecord({
183
+ id: file, file, status: 'fail',
184
+ failures: [`failed to load cases: ${error.message}`],
185
+ }))
186
+ process.stderr.write(`FAIL ${file}: failed to load cases: ${error.message}\n`)
187
+ continue
188
+ }
189
+ // Intra-file duplicate check
190
+ try {
191
+ detectDuplicateIds(cases)
192
+ } catch (error) {
193
+ records.push(createCaseRecord({
194
+ id: file, file, status: 'fail',
195
+ failures: [error.message],
196
+ }))
197
+ process.stderr.write(`FAIL ${file}: ${error.message}\n`)
198
+ continue
199
+ }
200
+ // Cross-file duplicate check (only add to seenIds after all pass)
201
+ let hasDuplicate = false
202
+ for (const c of cases) {
203
+ if (seenIds.has(c.id)) {
204
+ const message = `duplicate case id '${c.id}' (also in ${seenIds.get(c.id)})`
205
+ records.push(createCaseRecord({
206
+ id: c.id, file, mode: c.mode ?? 'real', status: 'fail',
207
+ failures: [message],
208
+ }))
209
+ process.stderr.write(`FAIL ${file}: ${message}\n`)
210
+ hasDuplicate = true
211
+ break
212
+ }
213
+ }
214
+ if (hasDuplicate) continue
215
+ for (const c of cases) seenIds.set(c.id, file)
216
+ for (const rawCase of cases) {
217
+ // Row-disable precedence (EVAL-014): a case's own `disableRows` —
218
+ // including an explicit `[]` ("disable nothing") — overrides the
219
+ // config-level default; only an undeclared field inherits it.
220
+ const evalCase = rawCase.disableRows === undefined && config.disableRows !== undefined
221
+ ? { ...rawCase, disableRows: config.disableRows }
222
+ : rawCase
223
+ const mode = evalCase.mode ?? 'real'
224
+ const skip = skipReason(evalCase, modeFilter)
225
+ if (skip !== undefined) {
226
+ records.push(createCaseRecord({
227
+ id: evalCase.id, file, mode, status: 'skip', skipReason: skip,
228
+ }))
229
+ say(`SKIP ${evalCase.id}: ${skip}`)
230
+ continue
231
+ }
232
+ say(`RUN ${evalCase.id} (${mode})...`)
233
+ const runStartedAt = Date.now()
234
+ let result
235
+ try {
236
+ result = await runEvalCase(evalCase, { profile, cliPath, dshRepoDir: repoDir, mode })
237
+ } catch (error) {
238
+ records.push(createCaseRecord({
239
+ id: evalCase.id, file, mode, status: 'fail',
240
+ failures: [`runner error: ${error.message}`],
241
+ durationMs: Date.now() - runStartedAt,
242
+ }))
243
+ process.stderr.write(`FAIL ${evalCase.id}: runner error: ${error.message}\n`)
244
+ continue
245
+ }
246
+ const durationMs = Date.now() - runStartedAt
247
+
248
+ if (result.trace === undefined) {
249
+ const artifactsDir = writeArtifacts(evalCase, result, mode)
250
+ records.push(createCaseRecord({
251
+ id: evalCase.id, file, mode, status: 'fail',
252
+ failures: [`no session trace materialized (exit ${result.exitCode}${result.timedOut ? ', timed out' : ''})`],
253
+ exitCode: result.exitCode, timedOut: result.timedOut,
254
+ durationMs, artifactsDir,
255
+ }))
256
+ process.stderr.write(
257
+ `FAIL ${evalCase.id}: no session trace materialized (exit ${result.exitCode}${result.timedOut ? ', timed out' : ''})\n`
258
+ + ` artifacts: ${artifactsDir}\n--- stderr ---\n${result.stderr}\n`,
259
+ )
260
+ continue
261
+ }
262
+
263
+ const failures = []
264
+ if (result.exitCode !== 0) {
265
+ // Headless SSOT: exit 0 iff the turn completed. A run that errored out
266
+ // must not pass on coincidentally satisfied matchers.
267
+ failures.push(`dsh CLI exited with code ${result.exitCode} (the turn did not complete)`)
268
+ }
269
+ for (const matcher of evalCase.expect) {
270
+ const outcome = matcher.check(result.trace)
271
+ if (!outcome.ok) failures.push(`${matcher.describe}: ${outcome.message}`)
272
+ }
273
+ if (result.timedOut) failures.push('run timed out')
274
+ if (result.inspectError !== undefined) failures.push(`workspace inspect failed: ${result.inspectError}`)
275
+
276
+ if (failures.length === 0) {
277
+ const artifactsDir = options.keepArtifacts ? writeArtifacts(evalCase, result, mode) : undefined
278
+ records.push(createCaseRecord({
279
+ id: evalCase.id, file, mode, status: 'pass',
280
+ exitCode: result.exitCode, timedOut: result.timedOut,
281
+ durationMs, ...(artifactsDir !== undefined ? { artifactsDir } : {}),
282
+ }))
283
+ say(`PASS ${evalCase.id}`)
284
+ } else {
285
+ const artifactsDir = writeArtifacts(evalCase, result, mode)
286
+ // Self-explaining failure for broken mock determinism (EVAL-014
287
+ // alternative): when non-host plugin injections are visible in the
288
+ // trace, the failure names them and the two framework-native exits —
289
+ // consumers stop rediscovering the mechanism from raw traces.
290
+ let hint
291
+ if (mode === 'mock') hint = mockDeterminismHint({ trace: result.trace, failures })
292
+ records.push(createCaseRecord({
293
+ id: evalCase.id, file, mode, status: 'fail', failures: hint ? [...failures, hint] : failures,
294
+ exitCode: result.exitCode, timedOut: result.timedOut,
295
+ durationMs, artifactsDir,
296
+ }))
297
+ process.stderr.write(`FAIL ${evalCase.id} (exit ${result.exitCode}):\n${failures.map(f => ` - ${f}`).join('\n')}\n`)
298
+ if (hint !== undefined) process.stderr.write(` ! ${hint}\n`)
299
+ process.stderr.write(` artifacts: ${artifactsDir}\n`)
300
+ }
301
+ }
302
+ }
303
+
304
+ const finishedAt = new Date().toISOString()
305
+ const report = buildRunReport({
306
+ profile,
307
+ repo: reportRepo,
308
+ cliSource,
309
+ modeFilter,
310
+ failOnSkip,
311
+ startedAt,
312
+ finishedAt,
313
+ records,
314
+ })
315
+
316
+ const reportTarget = options.report ?? config.report
317
+ if (reportTarget !== undefined) {
318
+ const reportPath = isAbsolute(reportTarget) ? reportTarget : resolve(process.cwd(), reportTarget)
319
+ try {
320
+ mkdirSync(dirname(reportPath), { recursive: true })
321
+ writeFileSync(reportPath, JSON.stringify(report, undefined, 2))
322
+ process.stderr.write(`report: ${reportPath}\n`)
323
+ } catch (error) {
324
+ process.stderr.write(`error: failed to write report '${reportPath}': ${error.message}\n`)
325
+ process.exit(2)
326
+ }
327
+ }
328
+
329
+ if (options.format === 'json') {
330
+ process.stdout.write(`${JSON.stringify(report, undefined, 2)}\n`)
331
+ } else {
332
+ const { summary } = report
333
+ process.stdout.write(`\n${summary.selected} selected, ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped\n`)
334
+ }
335
+ process.exit(reportExitCode(records, failOnSkip))
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Execute model-independent `*.review.mjs` experiments through dsh headless.
4
+ *
5
+ * Dry-run materializes live observations without touching dsh. Real runs write
6
+ * the shared task plus each independent review answer beside the experiment:
7
+ * `.runs/<experiment id>/`.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import { pathToFileURL } from 'node:url'
13
+ import { materializeReviewExperiment } from '../src/experiment/review.mjs'
14
+ import { runDshReviewExperiment } from '../src/adapters/dsh/review.mjs'
15
+ import { discoverFiles } from '../src/discovery.mjs'
16
+ import { loadEvalConfig } from '../src/config.mjs'
17
+ import { resolveDshCliChain } from '../src/cli.mjs'
18
+ import { renderReviewReport } from '../src/review-report.mjs'
19
+
20
+ function usage(error) {
21
+ const message = [
22
+ 'usage: dsh-review [--dry-run] [--runs N] [--profile NAME (default: headless) --repo DIR] [--timeout MS] <*.review.mjs or directories...>',
23
+ ' --profile/--repo may come from a dsh-eval.config.mjs found upward from cwd; flags override it.',
24
+ ].join('\n')
25
+ if (error) process.stderr.write(`${error}\n${message}\n`)
26
+ else process.stdout.write(`${message}\n`)
27
+ process.exit(error ? 2 : 0)
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ const options = { dryRun: false, runs: undefined, timeoutMs: undefined, profile: undefined, repo: undefined }
32
+ const paths = []
33
+ for (let index = 0; index < argv.length; index += 1) {
34
+ const arg = argv[index]
35
+ if (arg === '--dry-run') { options.dryRun = true; continue }
36
+ if (arg === '--runs') { options.runs = Number(argv[++index]); continue }
37
+ if (arg === '--profile') { options.profile = argv[++index]; continue }
38
+ if (arg === '--repo') { options.repo = argv[++index]; continue }
39
+ if (arg === '--timeout') { options.timeoutMs = Number(argv[++index]); continue }
40
+ if (arg === '-h' || arg === '--help') usage()
41
+ paths.push(arg)
42
+ }
43
+ if (paths.length === 0) usage('error: at least one review experiment path is required')
44
+ if (options.runs !== undefined && (!Number.isInteger(options.runs) || options.runs < 1)) usage('error: --runs must be a positive integer')
45
+ if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1)) usage('error: --timeout must be a positive integer')
46
+ return { options, paths }
47
+ }
48
+
49
+ function discover(path) {
50
+ return discoverFiles(path, '.review.mjs')
51
+ }
52
+
53
+ async function loadExperiment(file) {
54
+ const module = await import(pathToFileURL(file).href)
55
+ const experiment = module.default
56
+ if (experiment?.kind !== 'review') {
57
+ throw new Error(`${file}: default export must come from defineReviewExperiment(...)`)
58
+ }
59
+ return { ...experiment, __file: file }
60
+ }
61
+
62
+ function artifactDir(experiment) {
63
+ return join(dirname(experiment.__file), '.runs', experiment.id)
64
+ }
65
+
66
+ function writeMaterialized(experiment, materialized, extra = {}, reviewResult = undefined) {
67
+ const output = artifactDir(experiment)
68
+ mkdirSync(output, { recursive: true })
69
+ writeFileSync(join(output, 'task.txt'), materialized.task, 'utf8')
70
+ writeFileSync(join(output, 'observations.md'), materialized.observations, 'utf8')
71
+ writeFileSync(join(output, 'run.json'), JSON.stringify({
72
+ experimentId: experiment.id,
73
+ summary: experiment.summary,
74
+ rubric: String(experiment.rubric),
75
+ ...extra,
76
+ }, null, 2), 'utf8')
77
+ writeFileSync(join(output, 'review-report.md'), renderReviewReport({
78
+ experiment,
79
+ result: reviewResult,
80
+ observations: materialized.observations,
81
+ adapter: extra.adapter,
82
+ profile: extra.profile,
83
+ }), 'utf8')
84
+ return output
85
+ }
86
+
87
+ const { options, paths } = parseArgs(process.argv.slice(2))
88
+
89
+ // Config merge (EVAL-008): flags win over a `dsh-eval.config.mjs` found
90
+ // upward from cwd; profile falls back to the sterile default `headless`.
91
+ const { config } = await loadEvalConfig(process.cwd())
92
+ const profile = options.profile ?? config.profile ?? 'headless'
93
+ // CLI resolution (C6): `--repo` flag > resolution layer (node_modules) >
94
+ // config repo key (legacy). Dry-run never boots the CLI, so resolve lazily.
95
+ let cli = { cliPath: undefined, repoDir: undefined }
96
+ if (!options.dryRun) {
97
+ try {
98
+ const resolved = resolveDshCliChain({ repoFlag: options.repo, configRepo: config.repo })
99
+ cli = { cliPath: resolved.cli, repoDir: resolved.repo }
100
+ } catch (error) {
101
+ usage(`error: ${error.message}`)
102
+ }
103
+ }
104
+
105
+ for (const path of paths) {
106
+ if (!existsSync(resolve(path))) usage(`error: no such experiment path: ${path}`)
107
+ }
108
+ const files = paths.flatMap(path => discover(path)).sort()
109
+ if (files.length === 0) usage('error: no *.review.mjs experiment files found')
110
+
111
+ let failures = 0
112
+ for (const file of files) {
113
+ let experiment
114
+ try {
115
+ experiment = await loadExperiment(file)
116
+ if (options.dryRun) {
117
+ const materialized = await materializeReviewExperiment(experiment)
118
+ const output = writeMaterialized(experiment, materialized, { adapter: null, dryRun: true })
119
+ process.stdout.write(`DRY ${experiment.id}: ${output}\n`)
120
+ continue
121
+ }
122
+
123
+ process.stdout.write(`RUN ${experiment.id} (${options.runs ?? experiment.defaultRuns} reviews)...\n`)
124
+ const result = await runDshReviewExperiment(experiment, {
125
+ profile,
126
+ cliPath: cli.cliPath,
127
+ dshRepoDir: cli.repoDir,
128
+ runs: options.runs,
129
+ timeoutMs: options.timeoutMs,
130
+ })
131
+ const output = writeMaterialized(experiment, result, {
132
+ adapter: 'dsh-headless',
133
+ profile,
134
+ runs: result.runs,
135
+ }, result)
136
+ for (const attempt of result.attempts) {
137
+ const payload = attempt.result ?? {}
138
+ if (payload.stdout !== undefined) writeFileSync(join(output, `run-${attempt.index}.txt`), payload.stdout, 'utf8')
139
+ if (payload.stderr) writeFileSync(join(output, `run-${attempt.index}.stderr.txt`), payload.stderr, 'utf8')
140
+ if (payload.toolBoundaryEvidence) writeFileSync(join(output, `run-${attempt.index}.tool-boundary-evidence.json`), payload.toolBoundaryEvidence, 'utf8')
141
+ if (!attempt.ok) {
142
+ failures += 1
143
+ writeFileSync(join(output, `run-${attempt.index}.error.txt`), attempt.error, 'utf8')
144
+ process.stderr.write(`FAIL ${experiment.id} run ${attempt.index}: ${attempt.error}\n`)
145
+ }
146
+ }
147
+ if (result.attempts.every(attempt => attempt.ok)) process.stdout.write(`DONE ${experiment.id}: ${output}\n`)
148
+ } catch (error) {
149
+ failures += 1
150
+ process.stderr.write(`FAIL ${file}: ${error.message}\n`)
151
+ }
152
+ }
153
+
154
+ process.exit(failures === 0 ? 0 : 1)
package/docs/README.md ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: dsh-eval 文档索引——安装与宿主接线、review、matcher 全集、disableRows 契约、intent case 规约、报告结构与已知问题七篇的路由表
3
+ ---
4
+
5
+ # dsh-eval · docs index
6
+
7
+ | 文档 | 主题 |
8
+ |---|---|
9
+ | [host-wiring.md](host-wiring.md) | 安装与宿主接线:peer 依赖(dsh-llm)、构建 CLI、profile、凭证、spawn 要求 |
10
+ | [review.md](review.md) | comprehension review:实验定义、sterile profile、产物清单、六条固化规则 |
11
+ | [matchers.md](matchers.md) | trace matcher 与 mock helper 全集 |
12
+ | [disablerows.md](disablerows.md) | `disableRows` 与 turn-close 门禁边界契约 |
13
+ | [intent-cases.md](intent-cases.md) | real 意图 case 规约:何时写、断言面、守卫、CI 语义 |
14
+ | [report.md](report.md) | 机器可读报告(`--format json` / `--report`)结构 |
15
+ | [known-issues.md](known-issues.md) | 已知问题与规避 |
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: disableRows 边界契约——按 loader 行 id 禁用插件行的通用机制、turn-close 门禁 splice 与 finalText 失效的交互根因、case/config 取值优先级
3
+ ---
4
+
5
+ # `disableRows` 与 turn-close 门禁边界契约
6
+
7
+ `disableRows: string[]` 是通用机制:按 loader 行 id 在本次 run 的 overlay 里禁用任意插件行(`- id: <row> / disabled: true`,与 `session-title-llm` 同一跨层禁用机制)。框架对行 id 无任何内置知识,任何插件都可以成为禁用对象。取值优先级:**case 声明 > config 默认 > 不禁用**——case 级 `disableRows: []` 是合法的显式「全启用」,专门用来在默认禁 gate 行的包里恢复 gate 交互 case 的装载。
8
+
9
+ ## 为什么需要它
10
+
11
+ 首要使用场景是 turn-close blocking gate:gate 会在 turn 收尾自动运行并向 inbox splice 反馈。当 case 的**终态本身**就是 gate 判违规的状态(skip 语义的断链现场、非 git 工作区的 doc-link 报错现场等),splice 会驱动模型产生脚本之外的额外 step,`finalText*` 断言随之失效。
12
+
13
+ eval 的临时工作区通常**不是 git 仓库**——doc-link 类 gate 在其中只会以 git 报错成 blocking 并 splice 反馈耗尽脚本,因此测插件工具面的包普遍在 `dsh-eval.config.mjs` 里默认 `disableRows: ['gates']`。
14
+
15
+ ## 契约
16
+
17
+ - 默认**不声明** = 所选 profile 装载的插件照常运行(gate 交互 case——如断言 gate steer 的 `userMessageTextIncludes`——依赖此默认)。
18
+ - 声明 `disableRows: ['gates']`(case 级或 config 级)= 本次 run 禁用 gates 插件行(行 id 权威:`@catheadowl/dsh-extras` 包的 `cordis.patch.yml` `- id: gates`——兄弟 dsh 插件包),终态违规不再触发 splice,`finalText` 保持「脚本终步文本」的确定性语义。禁用其他插件行同理,行 id 以该插件包的 patch 声明为准。
19
+ - gate 交互 case 在默认禁用的包里声明 `disableRows: []` 显式恢复装载。
20
+ - 不依赖插件开关的断言出口:`assistantTextIncludes`(断言脚本台词出现过,不要求是最终文本)。终态干净时仍应优先 `finalText*`。
21
+ - per-gate 白名单(如只关某个 gate)暂不支持:per-gate disable 需要 gate 框架侧先提供 config 面。
22
+
23
+ ## 失败自解释
24
+
25
+ mock case 的 `finalText*` 失败若伴随 trace 里可见的**非宿主**插件注入 user 消息(gate 反馈、steer 等任何形态),失败输出会点名注入插件并提示上述出口(`disableRows`,或把交互纳入脚本预期 / `assistantTextIncludes`)——确定性被打破时框架当场解释机制,无需读 raw trace 排查。