@catheadowl/dsh-eval 0.2.0 → 0.3.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/docs/review.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: comprehension review 指南——defineReviewExperiment 实验定义、sterile profile 盲评运行、.runs 产物与 review-report 判读模板、六条评审规则
2
+ description: comprehension review 指南——defineReviewExperiment 实验定义、空白环境(默认禁树外插件行)盲评运行、.runs 产物与 review-report 判读模板、六条评审规则
3
3
  ---
4
4
 
5
5
  # Comprehension review
@@ -34,7 +34,7 @@ export default defineReviewExperiment({
34
34
  })
35
35
  ```
36
36
 
37
- 抽象层公开 `defineReviewExperiment`(稳定根入口)。实验执行与渲染原语——`materializeReviewExperiment`、`executeReviewExperiment`、标准 observation renderer(`renderObservationSections`;prompt 里的观测占位符常量是 `OBSERVATIONS_PLACEHOLDER`,必须恰好出现一次)——在 `@catheadowl/dsh-eval/experimental` 逃生面(见 [experimental.md](experimental.md),无兼容承诺)。自定义 executor 只需实现 `(task, context) => result`;接入其他 agent runtime 不需要改试验定义。
37
+ 抽象层公开 `defineReviewExperiment`(稳定根入口)。实验执行与渲染原语——`materializeReviewExperiment`、`executeReviewExperiment`、标准 observation renderer(`renderObservationSections`;prompt 里的观测占位符常量是 `OBSERVATIONS_PLACEHOLDER`,必须恰好出现一次)——在 `@catheadowl/dsh-eval/experimental` 逃生面(见 [experimental.md](experimental.md),无兼容承诺)。自定义 executor 只需实现 `(task, context) => result`;接入其他 agent runtime 不需要改试验定义。结果契约上,报告消费 `result.answer`(对任务的回答;未提供时回落 `result.stdout`),`result.stderr` 落盘为每轮 transcript——自定义 executor 提供这两者即可获得同等的产物与报告行为。
38
38
 
39
39
  ## CLI
40
40
 
@@ -43,19 +43,23 @@ export default defineReviewExperiment({
43
43
  dsh-review --dry-run <experiment file or directory>
44
44
 
45
45
  dsh-review \
46
- --profile <sterile-profile> \
46
+ --profile <profile> \
47
47
  --repo <deepseek-harness checkout> \
48
- [--runs 5] [--timeout 300000] \
48
+ [--runs 5] [--timeout 300000] [--keep-plugin-rows] \
49
49
  <experiment file or directory>
50
50
  ```
51
51
 
52
52
  `--profile`/`--repo` 可来自 `dsh-eval.config.mjs`(见 README),flags 覆盖 config。
53
53
 
54
- ## sterile profile 与工具边界
54
+ ## 空白环境(默认)与工具边界
55
55
 
56
- 真实运行使用**专用 sterile profile**(默认 `headless`,即宿主模板 `dsh-base` + `dsh-headless`,无树外插件):适配器生成一份 `--patch` 覆盖层禁用所有宿主模型可见工具(`tool-fs`、`tool-fs-search`、shell、web、subagent 等),并把 cwd 指向空临时目录——reviewer 只能从物化的观测文本推理。运行后解析 session trace 的 `request/header` 事件做**工具边界校验**:发现任何非预期工具即视为 adapter failure(证据写入 `.runs/<id>/run-N.tool-boundary-evidence.json`)。
56
+ 真实运行的 reviewer 会话默认在**空白环境**启动:适配器先照常暂存所选 profile,再枚举它组合出的**全部树外插件行**(`package.json` `dsh.profile.bundles` 中非 `@deepseek-ai/*` 的 bundle 各自 patch 文件里的行,加上 profile 自有 `cordis.patch.yml` 的行),在 `--patch` overlay 里逐行禁用——宿主 profile 装了什么 gates/插件都与 reviewer 无关,可复现性不再依赖「本机 profile 恰好干净」。白名单保留 reviewer 起不来就无测可言的接线行(`agent-default-model`、`session-title-llm`、`system-prompt`、`session-persistence-jsonl`);宿主模板工具行(`tool-fs`、shell、web、subagent 等)由静态清单继续禁用,cwd 指向空临时目录——reviewer 只能从物化的观测文本推理。运行后解析 session trace 的 `request/header` 事件做**工具边界校验**:发现任何非预期工具即视为 adapter failure(证据写入 `.runs/<id>/run-N.tool-boundary-evidence.json`)。
57
57
 
58
- > **运维前提**:profile staging 在真实 home 已有同名 profile 时**原样复制**(含已安装插件与 patch 层)。若本机 `headless` profile 装过树外插件,暂存后的 profile **不是无菌的**——工具边界校验会当场 fail-loud(这是设计的正确行为)。保持无菌:删掉本机 `headless` profile boot 重建出厂模板,或指定一个确认无插件的 profile。
58
+ **校验没跑成也要记账**:收不到 session artifact(宿主 artifact 命名或会话格式换代是最常见的成因)时 `validateToolBoundary` 返回 `status: 'not-executed'` 而**不是**通过,适配器把该状态与 seam 诊断挂上执行结果;报告头写明 `tool boundary: NOT EXECUTED on run(s) N`,对应轮次的 `run-N.txt` 顶部与报告条目也各写一行原因,`run.json``toolBoundaries` 给机器读。这是有意的取向:**「没验证」必须看起来像没验证**——静默 fail-open 会让一份「工具边界从未检查过」的 review 产物读起来完全正常。
59
+
60
+ **刻意复用宿主插件面**(例如要评审某插件自己的 gate 行为):加 `--keep-plugin-rows`——跳过树外行枚举,仅保留静态工具禁用,宿主 gates 恢复运行。
61
+
62
+ > 注意:无 `id` 的组合条目对 id 定位的禁用天然不可见(宿主 loader 语义),本包的树外 bundle 生态均为带 id 行形态;发现无 id 树外行时以工具边界校验 fail-loud 兜底。白名单是**按行名**无条件保留——若某树外 bundle 刻意以白名单名(如 `system-prompt`)insert 自己的行,该行不会被禁(威胁模型是本机自己的 profile,非对抗面);此类泄漏同样由工具边界校验兜底。
59
63
 
60
64
  ## 产物
61
65
 
@@ -63,9 +67,10 @@ dsh-review \
63
67
 
64
68
  - `observations.md`:本次实时物化的可见证据;
65
69
  - `task.txt`:实际发给每位 reviewer 的完整任务;
66
- - `run-N.txt` / stderr / error:各次独立运行;
67
- - `run.json`:experiment、rubric、adapter、profile run 数;
68
- - `review-report.md`:判读报告骨架——机器字段自动填(experiment/adapter/ profile/runsobservations 指纹、rubric 位置、每轮 reviewer 原文),三个 **人工判读栏目**留白待填:intentional design 命中项、新 red flag、下一步(改输出 / 改 rubric / 改 behavior case / 不处理)。刻意不做自动评分——review 层的价值在人工判断,报告只把判断物化成可归档、可对比的工程证据(dry-run 也会生成,runs 记 0)。
70
+ - `run-N.txt`:该轮 reviewer 的**答案**——trace 推导(首条插件注入消息之前的最后一条 assistant 文本,抗回合尾部劫持),无 trace 时回落到 stdout 的最终消息;劫持发生时原始最终消息另存 `run-N.stdout.txt`;工具边界校验未执行时,本条文件顶部先写一行 `[tool-boundary: NOT EXECUTED — <原因>]`(答案本身退回 stdout 的最终消息);
71
+ - `run-N.stderr.txt` / error:完整会话转录(判读回溯的 transcript 指针,报告每轮引用)/ 失败原因;
72
+ - `run.json`:experiment、rubric、adapterprofile、run 数与每轮 `toolBoundaries` 记账(`checked` / `not-executed` + 原因);
73
+ - `review-report.md`:判读报告骨架——机器字段自动填(experiment/adapter/ profile/runs、observations 指纹、rubric 位置、每轮 reviewer **答案**与 transcript 指针),三个 **人工判读栏目**留白待填:intentional design 命中项、新 red flag、下一步(改输出 / 改 rubric / 改 behavior case / 不处理)。刻意不做自动评分——review 层的价值在人工判断,报告只把判断物化成可归档、可对比的工程证据(dry-run 也会生成,runs 记 0)。
69
74
 
70
75
  ## 六条评审规则
71
76
 
@@ -17,10 +17,9 @@ const result = await runEvalCase(evalCase, { profile: 'headless', cliPath, mode:
17
17
  | 键 | 类型 | 语义 |
18
18
  |---|---|---|
19
19
  | `profile` | `string` | 必填。承载被测插件的 dsh profile(沙箱会暂存其 store,不污染真实 home)。 |
20
- | `cliPath` | `string` | 编译好的 dsh CLI 入口(`apps/cli/lib/bin.js`)绝对路径;优先于 `dshRepoDir`。程序化取值见下节。 |
20
+ | `cliPath` | `string` | 必填。编译好的 dsh CLI 入口(`apps/cli/lib/bin.js`)绝对路径(`resolveDshCliChain` 结果)。程序化取值见下节。 |
21
21
  | `mode` | `'real' \| 'mock'` | 覆写 case 自带的 mode;mock 需 `script.steps`。 |
22
22
  | `artifactsDir` | `string` | 提供则把 stdout/stderr/trace/session 日志拷贝到该目录(自动创建)。 |
23
- | `dshRepoDir` | `string` | **已弃用**:宿主 checkout 目录(从中拼出 CLI 路径)。下个 minor 删除——迁移到 `cliPath`。 |
24
23
 
25
24
  ## `cliPath` 从哪来:跨档关系(读我)
26
25
 
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "@catheadowl/dsh-eval",
3
3
  "description": "Agent eval framework over dsh headless runs: case runner, session-trace assertions, and a scripted mock-LLM layer for plugin intent tests.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/CatheadOwl/dsh-eval.git"
9
+ },
6
10
  "type": "module",
11
+ "packageManager": "pnpm@11.7.0",
7
12
  "main": "src/index.mjs",
8
13
  "exports": {
9
14
  ".": "./src/index.mjs",
@@ -28,6 +33,7 @@
28
33
  "docs/",
29
34
  "README.md",
30
35
  "README.zh.md",
36
+ "CHANGELOG.md",
31
37
  "LICENSE"
32
38
  ],
33
39
  "scripts": {
@@ -36,6 +42,6 @@
36
42
  "verify:experimental": "node scripts/verify-experimental-face.mjs",
37
43
  "verify:readme-i18n": "node scripts/verify-readme-i18n.mjs",
38
44
  "prepack": "node scripts/verify-manifest-face.mjs && node scripts/verify-publish-readiness.mjs && node scripts/verify-readme-i18n.mjs && node scripts/verify-experimental-face.mjs",
39
- "test": "node --test --test-isolation=none tests/trace.test.mjs tests/assertions.test.mjs tests/overlay.test.mjs tests/staging.test.mjs tests/runner.test.mjs tests/cli.test.mjs tests/discovery.test.mjs tests/review.test.mjs tests/report.test.mjs tests/config.test.mjs tests/review-report.test.mjs && node scripts/verify-publish-readiness.mjs && node scripts/verify-manifest-face.mjs && node scripts/verify-readme-i18n.mjs && node scripts/verify-experimental-face.mjs"
45
+ "test": "node --test --test-isolation=none tests/trace.test.mjs tests/assertions.test.mjs tests/overlay.test.mjs tests/staging.test.mjs tests/runner.test.mjs tests/cli.test.mjs tests/cli-report.test.mjs tests/discovery.test.mjs tests/review.test.mjs tests/tool-validation.test.mjs tests/report.test.mjs tests/config.test.mjs tests/review-report.test.mjs && node scripts/verify-publish-readiness.mjs && node scripts/verify-manifest-face.mjs && node scripts/verify-readme-i18n.mjs && node scripts/verify-experimental-face.mjs"
40
46
  }
41
47
  }
@@ -1,23 +1,23 @@
1
1
  /** dsh-headless execution adapter for model-independent review experiments. */
2
2
 
3
- import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { mkdtempSync, writeFileSync } from 'node:fs'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join, resolve } from 'node:path'
6
6
  import { executeReviewExperiment } from '../../experiment/review.mjs'
7
- import { CLI_RELATIVE_PATH } from '../../cli.mjs'
8
7
  import { overlayDisableRows } from '../../overlay.mjs'
9
8
  import {
10
- resolveRealDshHome, stageSandboxHome, teardownSandbox, spawnHeadlessDsh,
9
+ resolveRealDshHome, stageSandboxHome, stagedPluginRows, teardownSandbox, spawnHeadlessDsh,
11
10
  } from '../../sandbox.mjs'
12
- import { loadTraceDir } from '../../trace.mjs'
11
+ import { collectSessionTrace } from '../../trace.mjs'
13
12
  import { validateToolBoundary, renderToolBoundaryEvidence } from '../../tool-validation.mjs'
14
13
 
15
14
  /**
16
15
  * Model-facing tool rows every shipped dsh profile mounts from `dsh-base`.
17
- * The overlay disables all host tool rows as a supplementary guard; the
18
- * primary isolation comes from the sterile review profile (dsh-base +
19
- * dsh-headless only, no out-of-tree plugins). Post-run tool boundary
20
- * validation (see `validateToolBoundary`) detects any residual drift.
16
+ * A static supplementary guard on top of the blank-environment enumeration
17
+ * (host template tool rows never appear in `stagedPluginRows` they are the
18
+ * sterile baseline, so their ids are enumerated here instead). Post-run
19
+ * tool boundary validation (see `validateToolBoundary`) detects any residual
20
+ * drift.
21
21
  */
22
22
  const REVIEW_DISABLED_TOOL_ROWS = [
23
23
  'tool-bash',
@@ -39,43 +39,65 @@ const REVIEW_DISABLED_TOOL_ROWS = [
39
39
  'tool-workflow',
40
40
  ]
41
41
 
42
- /** Serialize the tool-less overlay: disable every host model-facing tool row. */
43
- function buildReviewOverlayYaml() {
44
- return overlayDisableRows(REVIEW_DISABLED_TOOL_ROWS)
45
- }
42
+ /**
43
+ * Rows the reviewer session must KEEP even when an out-of-tree bundle or the
44
+ * profile patch touches them: model wiring and the session log — without
45
+ * these the reviewer cannot answer at all and the trace (tool boundary
46
+ * check, run artifacts) never materializes. These ids are host-template
47
+ * rows; an out-of-tree patch row targeting one of them only overrides its
48
+ * config, so keeping it enabled stays safe.
49
+ */
50
+ const REVIEW_REQUIRED_ROWS = new Set([
51
+ 'agent-default-model',
52
+ 'session-title-llm',
53
+ 'system-prompt',
54
+ 'session-persistence-jsonl',
55
+ ])
46
56
 
47
- /** Resolve and validate the compiled dsh CLI entry point. */
48
- export function resolveDshCli(dshRepoDir) {
49
- const repoDir = resolve(dshRepoDir)
50
- const cli = join(repoDir, ...CLI_RELATIVE_PATH.split(/[\\/]/))
51
- if (!existsSync(cli)) {
52
- throw new Error(`no compiled dsh CLI at '${cli}' (build deepseek-harness first)`)
53
- }
54
- return cli
57
+ /**
58
+ * Serialize the blank-environment overlay: disable every plugin row the
59
+ * staged profile composes beyond the host templates (`stagedPluginRows` —
60
+ * out-of-tree bundle rows + profile patch rows), UNION the static host tool
61
+ * rows, MINUS the model/session wiring the reviewer needs. With
62
+ * `keepPluginRows` (explicit opt-in to study the host plugin face itself)
63
+ * the enumeration is skipped and only the static tool lockdown remains.
64
+ */
65
+ function buildReviewOverlayYaml(pluginRows, { keepPluginRows }) {
66
+ const disabled = new Set(keepPluginRows ? [] : pluginRows)
67
+ for (const row of REVIEW_DISABLED_TOOL_ROWS) disabled.add(row)
68
+ for (const row of REVIEW_REQUIRED_ROWS) disabled.delete(row)
69
+ return overlayDisableRows([...disabled])
55
70
  }
56
71
 
57
- /** The CLI entry for an executor: explicit cliPath (C6 chain result) wins;
58
- * otherwise fall back to the legacy repo form. Neither being set is a caller
59
- * bug the CLI bins already catch — this guard serves direct API consumers. */
72
+ /** The CLI entry for an executor: the explicit cliPath (a `resolveDshCliChain`
73
+ * result). Its absence is a caller bug the CLI bins already catch this
74
+ * guard serves direct API consumers. */
60
75
  function executorCli(options) {
61
- if (options.cliPath !== undefined) return resolve(options.cliPath)
62
- if (options.dshRepoDir !== undefined) return resolveDshCli(options.dshRepoDir)
63
- throw new Error('review adapter needs a CLI location: pass cliPath (C6 chain result) or dshRepoDir')
76
+ if (options.cliPath === undefined) {
77
+ throw new Error('review adapter needs options.cliPath (a resolveDshCliChain result)')
78
+ }
79
+ return resolve(options.cliPath)
64
80
  }
65
81
 
66
82
  /**
67
83
  * Create an executor compatible with executeReviewExperiment.
68
84
  *
69
- * The executor boots a sterile review profile (default: the host's
70
- * `headless` template, bundles = dsh-base + dsh-headless, no out-of-tree
71
- * plugins) in an isolated DSH_HOME (staging/teardown mechanics shared with
72
- * the behavior runner via sandbox.mjs), disables every host tool row via
73
- * overlay, and validates the tool boundary after the run. `options.profile`
74
- * must name a profile whose installed plugin set is empty or review-safe.
85
+ * The executor boots a blank review environment in an isolated DSH_HOME
86
+ * (staging/teardown mechanics shared with the behavior runner via
87
+ * sandbox.mjs): the staged profile's every out-of-tree plugin row and every
88
+ * host model-facing tool row is disabled via overlay (blank =
89
+ * dsh-base/dsh-headless templates + model wiring, nothing else regardless
90
+ * of what the host profile carries), and the tool boundary is validated
91
+ * after the run.
75
92
  *
76
93
  * @param {object} options
77
- * @param {string} [options.profile='headless'] - the sterile review profile.
94
+ * @param {string} [options.profile='headless'] - the review profile (host
95
+ * templates + whatever the host machine carries; plugin rows are disabled
96
+ * by the overlay anyway).
78
97
  * @param {Set<string>} [options.allowedTools] - tool names permitted in the reviewer's session (default: empty).
98
+ * @param {boolean} [options.keepPluginRows=false] - opt back into the host
99
+ * profile's plugin face (e.g. to review a plugin's own gate behavior);
100
+ * only the static tool lockdown remains.
79
101
  */
80
102
  export function createDshHeadlessReviewExecutor(options) {
81
103
  const cli = executorCli(options)
@@ -85,6 +107,7 @@ export function createDshHeadlessReviewExecutor(options) {
85
107
  }
86
108
  const timeoutMs = options.timeoutMs ?? 300_000
87
109
  const allowedTools = options.allowedTools ?? new Set()
110
+ const keepPluginRows = options.keepPluginRows ?? false
88
111
 
89
112
  return async function executeWithDsh(task) {
90
113
  // A fresh process alone is not enough: dsh also stores settings, titles,
@@ -93,9 +116,18 @@ export function createDshHeadlessReviewExecutor(options) {
93
116
  // while retaining the selected profile's model config and plugin links.
94
117
  const runDir = mkdtempSync(join(tmpdir(), 'dsh-review-'))
95
118
  const dshHome = join(runDir, 'dsh-home')
96
- const overlayPath = join(runDir, 'review-overlay.yml')
97
- writeFileSync(overlayPath, buildReviewOverlayYaml(), 'utf8')
98
119
  stageSandboxHome(options.dshHome ?? resolveRealDshHome(), dshHome, profile)
120
+ // Blank environment by DEFAULT (review-blank-environment TODO): disable
121
+ // every row the staged profile composes beyond the host templates, so a
122
+ // host profile carrying out-of-tree plugins (gates included) can no
123
+ // longer steer or crash the reviewer. `keepPluginRows` opts back into
124
+ // the host plugin face deliberately. Enumeration happens on the STAGED
125
+ // copy, after staging — the staged home is what actually boots.
126
+ const overlayPath = join(runDir, 'review-overlay.yml')
127
+ writeFileSync(overlayPath, buildReviewOverlayYaml(
128
+ stagedPluginRows(dshHome, profile),
129
+ { keepPluginRows },
130
+ ), 'utf8')
99
131
 
100
132
  try {
101
133
  const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
@@ -120,28 +152,43 @@ export function createDshHeadlessReviewExecutor(options) {
120
152
 
121
153
  // Post-run tool boundary check: parse the session
122
154
  // trace, verify no unexpected tools were mounted in the reviewer's
123
- // session, fail the run on violation. An absent session log skips
124
- // the check gracefully (accepted fail-open).
155
+ // session, fail the run on violation. A missing session log does NOT
156
+ // skip the check silently: `validateToolBoundary` reports
157
+ // `status: 'not-executed'`, and the adapter carries that fact (plus the
158
+ // seam diagnosis) on the result so the report and run artifacts state
159
+ // that the boundary guarantee was not verified (EVAL-021).
125
160
  // Validation inspects the main session only (buildTrace selects
126
161
  // non-subagent logs); plugin tools leaking in a subagent session
127
162
  // would not be caught — irrelevant in review where the overlay
128
163
  // disables every subagent tool row.
129
- const trace = loadTraceDir(join(dshHome, 'sessions'))
130
- if (trace) {
131
- const validation = validateToolBoundary(trace, { allowedTools })
132
- result.toolValidation = validation
133
- if (!validation.ok) {
134
- // Attach evidence for the caller to persist (the adapter's
135
- // runDir is ephemeral — removed by the finally block). The
136
- // bin writes this to `.runs/<id>/tool-boundary-evidence.json`.
137
- result.toolBoundaryEvidence = renderToolBoundaryEvidence(validation, { runDir, profile })
138
- const boundaryError = new Error(
139
- `tool boundary violation: unexpected tools [${validation.unexpected.join(', ')}]`,
140
- )
141
- boundaryError.result = result
142
- throw boundaryError
143
- }
164
+ const { trace, gap } = collectSessionTrace(join(dshHome, 'sessions'))
165
+ const validation = validateToolBoundary(trace, { allowedTools })
166
+ result.toolValidation = validation
167
+ if (validation.status === 'not-executed') {
168
+ // `traceGap` mirrors the behavior runner's field name for the same
169
+ // seam diagnosis; the bin writes both into run-N.txt and the report.
170
+ result.traceGap = gap
171
+ }
172
+ if (validation.status === 'checked' && !validation.ok) {
173
+ // Attach evidence for the caller to persist (the adapter's
174
+ // runDir is ephemeral removed by the finally block). The
175
+ // bin writes this to `.runs/<id>/tool-boundary-evidence.json`.
176
+ result.toolBoundaryEvidence = renderToolBoundaryEvidence(validation, { runDir, profile })
177
+ const boundaryError = new Error(
178
+ `tool boundary violation: unexpected tools [${validation.unexpected.join(', ')}]`,
179
+ )
180
+ boundaryError.result = result
181
+ throw boundaryError
144
182
  }
183
+ // The ANSWER to the task, not the last message: stdout carries the
184
+ // headless CLI's final assistant message — whatever the reviewer
185
+ // said LAST. If any tail interaction intervened (a turn-close gate
186
+ // splice that slipped past the blank environment, an infra
187
+ // complaint), stdout holds that instead of the analysis. The
188
+ // trace's answerText (last assistant text before the first
189
+ // plugin-sourced injection) IS the analysis; stdout remains the
190
+ // fallback for trace-less runs (recorded by `traceGap` above).
191
+ result.answer = trace !== undefined && trace.answerText !== '' ? trace.answerText : stdout
145
192
 
146
193
  return result
147
194
  } finally {