@catheadowl/dsh-eval 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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`,必须恰好出现一次)。自定义 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,21 @@ 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
+ **刻意复用宿主插件面**(例如要评审某插件自己的 gate 行为):加 `--keep-plugin-rows`——跳过树外行枚举,仅保留静态工具禁用,宿主 gates 恢复运行。
59
+
60
+ > 注意:无 `id` 的组合条目对 id 定位的禁用天然不可见(宿主 loader 语义),本包的树外 bundle 生态均为带 id 行形态;发现无 id 树外行时以工具边界校验 fail-loud 兜底。白名单是**按行名**无条件保留——若某树外 bundle 刻意以白名单名(如 `system-prompt`)insert 自己的行,该行不会被禁(威胁模型是本机自己的 profile,非对抗面);此类泄漏同样由工具边界校验兜底。
59
61
 
60
62
  ## 产物
61
63
 
@@ -63,11 +65,12 @@ dsh-review \
63
65
 
64
66
  - `observations.md`:本次实时物化的可见证据;
65
67
  - `task.txt`:实际发给每位 reviewer 的完整任务;
66
- - `run-N.txt` / stderr / error:各次独立运行;
68
+ - `run-N.txt`:该轮 reviewer 的**答案**——trace 推导(首条插件注入消息之前的最后一条 assistant 文本,抗回合尾部劫持),无 trace 时回落到 stdout 的最终消息;劫持发生时原始最终消息另存 `run-N.stdout.txt`;
69
+ - `run-N.stderr.txt` / error:完整会话转录(判读回溯的 transcript 指针,报告每轮引用)/ 失败原因;
67
70
  - `run.json`:experiment、rubric、adapter、profile 与 run 数;
68
- - `review-report.md`:判读报告骨架——机器字段自动填(experiment/adapter/ profile/runs、observations 指纹、rubric 位置、每轮 reviewer 原文),三个 **人工判读栏目**留白待填:intentional design 命中项、新 red flag、下一步(改输出 / 改 rubric / 改 behavior case / 不处理)。刻意不做自动评分——review 层的价值在人工判断,报告只把判断物化成可归档、可对比的工程证据(dry-run 也会生成,runs 记 0)。
71
+ - `review-report.md`:判读报告骨架——机器字段自动填(experiment/adapter/ profile/runs、observations 指纹、rubric 位置、每轮 reviewer **答案**与 transcript 指针),三个 **人工判读栏目**留白待填:intentional design 命中项、新 red flag、下一步(改输出 / 改 rubric / 改 behavior case / 不处理)。刻意不做自动评分——review 层的价值在人工判断,报告只把判断物化成可归档、可对比的工程证据(dry-run 也会生成,runs 记 0)。
69
72
 
70
- ## 从早期 eval 固化下来的六条规则
73
+ ## 六条评审规则
71
74
 
72
75
  1. **冻结输入,实时投影输出。** fixture 保存 raw SDK result、合成知识库或调用参数;`observe()` 必须调用当前构建产物。不要提交一份会随实现漂移的 projected-output 快照。
73
76
  2. **盲 prompt 与隐藏 rubric 分离。** `prompt.md` 只能含问题和 `{{EVAL_OBSERVATIONS}}`;答案键、预期 next action、intentional design 只在 `rubric.md`。
@@ -0,0 +1,39 @@
1
+ ---
2
+ description: rowConfig 边界契约——case 级按 loader 行 id 覆写行 config 的机制、整段替换语义与「重述所需键」义务、叶值形状限制、与 disableRows 的分工
3
+ ---
4
+
5
+ # rowConfig:case 级行 config 覆写
6
+
7
+ `rowConfig: Record<rowId, config对象>` 让一个 case 在**本次 run 的 overlay** 里覆写插件行的 config。框架对行 id 与键语义零内置知识(键由目标插件自定义),只做形状校验。典型用例是臂式实验:同一 case 跑两臂,差异臂用 `rowConfig: { prompt: { disabledProviders: ['<provider>'] } }` 关掉单个 provider,而不用 `disableRows` 禁掉整行(那会连工具面一起摘掉,制造混淆变量)。
8
+
9
+ ## 机制
10
+
11
+ - 序列化为 overlay 条目 `- id: <row>` + `config:` 键值块(`buildOverlayYaml`),与 `session-persistence-jsonl` 重根、`persona`、`disableRows` 走同一条 per-run overlay 通道;
12
+ - 取值优先级同 overlay 语义:覆盖 bundle/patch 层为该行声明的 config。
13
+
14
+ ## 整段替换语义(最大的坑)
15
+
16
+ cordis patch 层的 config 覆写是**整段替换**,不是深合并:`rowConfig` 声明的键集合就是该行生效的**全部** config。行原本带的其他键(如 prompt 行的 `providerTimeoutMs` / `totalTimeoutMs` / `renderBudgetChars`)不会自动保留——**用到哪个键就在 rowConfig 里重述哪个**。这与「惰性只写差异」的直觉相反,是宿主 patch 语义的直接推论(参见 dsh 宿主文档对 patch 的说明:按 id 定位行、整段替换其 config)。
17
+
18
+ ## 形状限制
19
+
20
+ - 叶值只支持**标量**(string / number / boolean)或**标量数组**(如 `disabledProviders: ['a', 'b']`,YAML flow 序列发射);
21
+ - 嵌套对象不支持(`validateRowConfig` 拒绝)——需要嵌套 config 的行请走自己的 profile patch,不进 case;
22
+ - 校验双点:discovery 加载期与 `runEvalCase` 执行期同一份 `validateRowConfig`。
23
+
24
+ ## 与 disableRows 的分工
25
+
26
+ | | `disableRows` | `rowConfig` |
27
+ |---|---|---|
28
+ | 效果 | 整行禁用(插件完全不装载) | 行照常装载,config 被覆写 |
29
+ | 适用 | 该 case 不需要该插件的任何面 | 该 case 需要插件但想改其行为参数 |
30
+ | 组合 | 同一行同时出现在两处是矛盾声明(禁用的行无 config 可言),避免 | |
31
+
32
+ ## 校验示例
33
+
34
+ ```js
35
+ // 合法
36
+ rowConfig: { prompt: { disabledProviders: ['breadcrumb-description-enricher'], totalTimeoutMs: 5000 } }
37
+ // 非法:嵌套对象
38
+ rowConfig: { prompt: { a: { b: 1 } } } // validateRowConfig 拒绝
39
+ ```
@@ -0,0 +1,42 @@
1
+ ---
2
+ description: 程序化 runner API——runEvalCase 的 options 契约(SDK 承诺)、EvalRunResult 字段、DSH_EVAL_KEEP_TMP 逃生、与 experimental 档 resolveDshCliChain 的跨档关系
3
+ ---
4
+
5
+ # 程序化 runner API
6
+
7
+ `runEvalCase` 从包根导入,是 SDK 稳定面的一部分(semver 承诺):
8
+
9
+ ```js
10
+ import { runEvalCase } from '@catheadowl/dsh-eval'
11
+
12
+ const result = await runEvalCase(evalCase, { profile: 'headless', cliPath, mode: 'real' })
13
+ ```
14
+
15
+ ## options(SDK 承诺)
16
+
17
+ | 键 | 类型 | 语义 |
18
+ |---|---|---|
19
+ | `profile` | `string` | 必填。承载被测插件的 dsh profile(沙箱会暂存其 store,不污染真实 home)。 |
20
+ | `cliPath` | `string` | 编译好的 dsh CLI 入口(`apps/cli/lib/bin.js`)绝对路径;优先于 `dshRepoDir`。程序化取值见下节。 |
21
+ | `mode` | `'real' \| 'mock'` | 覆写 case 自带的 mode;mock 需 `script.steps`。 |
22
+ | `artifactsDir` | `string` | 提供则把 stdout/stderr/trace/session 日志拷贝到该目录(自动创建)。 |
23
+ | `dshRepoDir` | `string` | **已弃用**:宿主 checkout 目录(从中拼出 CLI 路径)。下个 minor 删除——迁移到 `cliPath`。 |
24
+
25
+ ## `cliPath` 从哪来:跨档关系(读我)
26
+
27
+ 稳定档不提供 CLI 定位器;程序化解析走 experimental 档的 `resolveDshCliChain`(`import '@catheadowl/dsh-eval/experimental'`,见 [experimental.md](experimental.md))。**该档无兼容承诺**:若你的脚本不能接受随 minor 跟进,可自行传入 `cliPath`(如来自你自己的部署清单),`runEvalCase` 不假设来源。
28
+
29
+ ## EvalRunResult 字段
30
+
31
+ | 字段 | 语义 |
32
+ |---|---|
33
+ | `caseId` / `mode` / `task` | 回显 case 标识。 |
34
+ | `exitCode` | headless CLI 退出码(0 = turn 完成)。 |
35
+ | `timedOut` | 是否超时被杀。 |
36
+ | `stdout` / `stderr` | CLI 原始输出(stdout 含最终 assistant 文本与启动 chatter)。 |
37
+ | `trace` | session 事件投影(形状见 [matchers.md](matchers.md) 的 trace 形状表);无日志时为 `undefined`。 |
38
+ | `sessionLogs` | 清理前的原始 session artifact 文本数组。 |
39
+ | `inspectError` | case 的 `inspect` 抛错时的错误文本。 |
40
+ | `runDir` | 本次运行的沙箱目录——**默认清理**;设环境变量 `DSH_EVAL_KEEP_TMP=1` 保留(失败诊断用)。 |
41
+
42
+ 清理是全路径 `try/finally`:`prepare` 抛错、mock 校验失败、spawn 失败都会清理,真实 profile store 永不被污染。
package/package.json CHANGED
@@ -1,10 +1,22 @@
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.1.0",
4
+ "version": "0.2.1",
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",
13
+ "exports": {
14
+ ".": "./src/index.mjs",
15
+ "./experimental": "./src/experimental.mjs",
16
+ "./bin/dsh-eval": "./bin/dsh-eval.mjs",
17
+ "./bin/dsh-review": "./bin/dsh-review.mjs",
18
+ "./package.json": "./package.json"
19
+ },
8
20
  "peerDependencies": {
9
21
  "@deepseek-ai/dsh-llm": "*"
10
22
  },
@@ -20,12 +32,16 @@
20
32
  "src/",
21
33
  "docs/",
22
34
  "README.md",
35
+ "README.zh.md",
36
+ "CHANGELOG.md",
23
37
  "LICENSE"
24
38
  ],
25
39
  "scripts": {
26
40
  "verify:publish": "node scripts/verify-publish-readiness.mjs",
27
41
  "verify:face": "node scripts/verify-manifest-face.mjs",
28
- "prepack": "node scripts/verify-manifest-face.mjs && node scripts/verify-publish-readiness.mjs",
29
- "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"
42
+ "verify:experimental": "node scripts/verify-experimental-face.mjs",
43
+ "verify:readme-i18n": "node scripts/verify-readme-i18n.mjs",
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",
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/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"
30
46
  }
31
47
  }
@@ -1,192 +1,210 @@
1
- /** dsh-headless execution adapter for model-independent review experiments. */
2
-
3
- import { spawn } from 'node:child_process'
4
- import {
5
- chmodSync,
6
- copyFileSync,
7
- existsSync,
8
- mkdirSync,
9
- mkdtempSync,
10
- rmSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from 'node:fs'
14
- import { homedir, tmpdir } from 'node:os'
15
- import { join, resolve } from 'node:path'
16
- import { executeReviewExperiment } from '../../experiment/review.mjs'
17
- import { stageProfileStore } from '../../runner.mjs'
18
- import { loadTraceDir } from '../../trace.mjs'
19
- import { validateToolBoundary, renderToolBoundaryEvidence } from '../../tool-validation.mjs'
20
-
21
- /**
22
- * Model-facing tool rows every shipped dsh profile mounts from `dsh-base`.
23
- * The overlay disables all host tool rows as a supplementary guard; the
24
- * primary isolation comes from the sterile review profile (dsh-base +
25
- * dsh-headless only, no out-of-tree plugins). Post-run tool boundary
26
- * validation (see `validateToolBoundary`) detects any residual drift.
27
- */
28
- const REVIEW_DISABLED_TOOL_ROWS = [
29
- 'tool-bash',
30
- 'tool-pwsh',
31
- 'tool-jobs',
32
- 'tool-fs',
33
- 'tool-fs-search',
34
- 'tool-skill',
35
- 'tool-todo',
36
- 'tool-goal',
37
- 'tool-ralph',
38
- 'tool-str-replace-editor',
39
- 'tool-web',
40
- 'tool-subagent-control',
41
- 'tool-subagent-list-agents',
42
- 'tool-subagent',
43
- 'tool-subagent-fork',
44
- 'tool-subagent-report',
45
- 'tool-workflow',
46
- ]
47
-
48
- /** Serialize the tool-less overlay: disable every host model-facing tool row. */
49
- function buildReviewOverlayYaml() {
50
- return REVIEW_DISABLED_TOOL_ROWS.map((id) => `- id: ${id}\n disabled: true\n`).join('\n')
51
- }
52
-
53
- /** Resolve and validate the compiled dsh CLI entry point. */
54
- export function resolveDshCli(dshRepoDir) {
55
- const repoDir = resolve(dshRepoDir)
56
- const cli = join(repoDir, 'apps', 'cli', 'lib', 'bin.js')
57
- if (!existsSync(cli)) {
58
- throw new Error(`no compiled dsh CLI at '${cli}' (build deepseek-harness first)`)
59
- }
60
- return cli
61
- }
62
-
63
- /** The CLI entry for an executor: explicit cliPath (C6 chain result) wins;
64
- * otherwise fall back to the legacy repo form. Neither being set is a caller
65
- * bug the CLI bins already catch — this guard serves direct API consumers. */
66
- function executorCli(options) {
67
- if (options.cliPath !== undefined) return resolve(options.cliPath)
68
- if (options.dshRepoDir !== undefined) return resolveDshCli(options.dshRepoDir)
69
- throw new Error('review adapter needs a CLI location: pass cliPath (C6 chain result) or dshRepoDir')
70
- }
71
-
72
- /**
73
- * Create an executor compatible with executeReviewExperiment.
74
- *
75
- * The executor boots a sterile review profile (default: the host's
76
- * `headless` template, bundles = dsh-base + dsh-headless, no out-of-tree
77
- * plugins) in an isolated DSH_HOME, disables every host tool row via
78
- * overlay, and validates the tool boundary after the run. `options.profile`
79
- * must name a profile whose installed plugin set is empty or review-safe.
80
- *
81
- * @param {object} options
82
- * @param {string} [options.profile='headless'] - the sterile review profile.
83
- * @param {Set<string>} [options.allowedTools] - tool names permitted in the reviewer's session (default: empty).
84
- */
85
- export function createDshHeadlessReviewExecutor(options) {
86
- const cli = executorCli(options)
87
- const profile = options.profile ?? 'headless'
88
- if (typeof profile !== 'string' || profile.length === 0) {
89
- throw new TypeError('dsh review adapter requires a profile')
90
- }
91
- const timeoutMs = options.timeoutMs ?? 300_000
92
- const allowedTools = options.allowedTools ?? new Set()
93
-
94
- return async function executeWithDsh(task) {
95
- // A fresh process alone is not enough: dsh also stores settings, titles,
96
- // and sessions below DSH_HOME. Reuse the behavior harness's proven profile
97
- // staging strategy so every reviewer receives an isolated runtime state
98
- // while retaining the selected profile's model config and plugin links.
99
- const runDir = mkdtempSync(join(tmpdir(), 'dsh-review-'))
100
- const dshHome = join(runDir, 'dsh-home')
101
- const overlayPath = join(runDir, 'review-overlay.yml')
102
- writeFileSync(overlayPath, buildReviewOverlayYaml(), 'utf8')
103
- const realHome = options.dshHome
104
- ?? ((process.env.DSH_HOME ?? '').trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh'))
105
- mkdirSync(dshHome, { recursive: true })
106
- const junctions = stageProfileStore(realHome, dshHome, profile)
107
- const realCredentials = join(realHome, '.credentials.yaml')
108
- if (existsSync(realCredentials)) {
109
- const credentialsCopy = join(dshHome, '.credentials.yaml')
110
- copyFileSync(realCredentials, credentialsCopy)
111
- try { chmodSync(credentialsCopy, 0o600) } catch { /* best-effort */ }
112
- }
113
-
114
- try {
115
- const child = spawn(process.execPath, [cli, '--profile', profile, '--patch', overlayPath, task], {
116
- cwd: options.cwd ?? runDir,
117
- env: {
118
- ...process.env,
119
- ...options.env,
120
- DSH_HOME: dshHome,
121
- DSH_TELEMETRY_DISABLED: '1',
122
- },
123
- })
124
- let stdout = ''
125
- let stderr = ''
126
- child.stdout.on('data', chunk => { stdout += chunk })
127
- child.stderr.on('data', chunk => { stderr += chunk })
128
-
129
- let timedOut = false
130
- const timer = setTimeout(() => {
131
- timedOut = true
132
- child.kill('SIGTERM')
133
- }, timeoutMs)
134
- let exitCode
135
- try {
136
- exitCode = await new Promise((resolveExit, reject) => {
137
- child.on('error', reject)
138
- child.on('exit', code => resolveExit(code ?? 1))
139
- })
140
- } finally {
141
- clearTimeout(timer)
142
- }
143
-
144
- const result = { stdout, stderr, exitCode, timedOut, profile, cli, runDir }
145
- if (exitCode !== 0 || timedOut) {
146
- const error = new Error(`dsh reviewer exited with code ${exitCode}${timedOut ? ' after timeout' : ''}`)
147
- error.result = result
148
- throw error
149
- }
150
-
151
- // Post-run tool boundary check: parse the session
152
- // trace, verify no unexpected tools were mounted in the reviewer's
153
- // session, fail the run on violation. An absent session log skips
154
- // the check gracefully (accepted fail-open).
155
- // Validation inspects the main session only (buildTrace selects
156
- // non-subagent logs); plugin tools leaking in a subagent session
157
- // would not be caught irrelevant in review where the overlay
158
- // disables every subagent tool row.
159
- const trace = loadTraceDir(join(dshHome, 'sessions'))
160
- if (trace) {
161
- const validation = validateToolBoundary(trace, { allowedTools })
162
- result.toolValidation = validation
163
- if (!validation.ok) {
164
- // Attach evidence for the caller to persist (the adapter's
165
- // runDir is ephemeral removed by the finally block). The
166
- // bin writes this to `.runs/<id>/tool-boundary-evidence.json`.
167
- result.toolBoundaryEvidence = renderToolBoundaryEvidence(validation, { runDir, profile })
168
- const boundaryError = new Error(
169
- `tool boundary violation: unexpected tools [${validation.unexpected.join(', ')}]`,
170
- )
171
- boundaryError.result = result
172
- throw boundaryError
173
- }
174
- }
175
-
176
- return result
177
- } finally {
178
- if (process.env.DSH_REVIEW_KEEP_TMP !== '1') {
179
- for (const junction of junctions) {
180
- try { unlinkSync(junction) } catch { /* already absent */ }
181
- }
182
- rmSync(runDir, { recursive: true, force: true })
183
- }
184
- }
185
- }
186
- }
187
-
188
- /** Run a review experiment through fresh dsh headless processes. */
189
- export async function runDshReviewExperiment(experiment, options) {
190
- const executor = createDshHeadlessReviewExecutor(options)
191
- return executeReviewExperiment(experiment, executor, { runs: options.runs })
192
- }
1
+ /** dsh-headless execution adapter for model-independent review experiments. */
2
+
3
+ import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { executeReviewExperiment } from '../../experiment/review.mjs'
7
+ import { CLI_RELATIVE_PATH } from '../../cli.mjs'
8
+ import { overlayDisableRows } from '../../overlay.mjs'
9
+ import {
10
+ resolveRealDshHome, stageSandboxHome, stagedPluginRows, teardownSandbox, spawnHeadlessDsh,
11
+ } from '../../sandbox.mjs'
12
+ import { loadTraceDir } from '../../trace.mjs'
13
+ import { validateToolBoundary, renderToolBoundaryEvidence } from '../../tool-validation.mjs'
14
+
15
+ /**
16
+ * Model-facing tool rows every shipped dsh profile mounts from `dsh-base`.
17
+ * A static supplementary guard on top of the blank-environment enumeration
18
+ * (host template tool rows never appear in `stagedPluginRows` — they are the
19
+ * sterile baseline, so their ids are enumerated here instead). Post-run
20
+ * tool boundary validation (see `validateToolBoundary`) detects any residual
21
+ * drift.
22
+ */
23
+ const REVIEW_DISABLED_TOOL_ROWS = [
24
+ 'tool-bash',
25
+ 'tool-pwsh',
26
+ 'tool-jobs',
27
+ 'tool-fs',
28
+ 'tool-fs-search',
29
+ 'tool-skill',
30
+ 'tool-todo',
31
+ 'tool-goal',
32
+ 'tool-ralph',
33
+ 'tool-str-replace-editor',
34
+ 'tool-web',
35
+ 'tool-subagent-control',
36
+ 'tool-subagent-list-agents',
37
+ 'tool-subagent',
38
+ 'tool-subagent-fork',
39
+ 'tool-subagent-report',
40
+ 'tool-workflow',
41
+ ]
42
+
43
+ /**
44
+ * Rows the reviewer session must KEEP even when an out-of-tree bundle or the
45
+ * profile patch touches them: model wiring and the session log — without
46
+ * these the reviewer cannot answer at all and the trace (tool boundary
47
+ * check, run artifacts) never materializes. These ids are host-template
48
+ * rows; an out-of-tree patch row targeting one of them only overrides its
49
+ * config, so keeping it enabled stays safe.
50
+ */
51
+ const REVIEW_REQUIRED_ROWS = new Set([
52
+ 'agent-default-model',
53
+ 'session-title-llm',
54
+ 'system-prompt',
55
+ 'session-persistence-jsonl',
56
+ ])
57
+
58
+ /**
59
+ * Serialize the blank-environment overlay: disable every plugin row the
60
+ * staged profile composes beyond the host templates (`stagedPluginRows` —
61
+ * out-of-tree bundle rows + profile patch rows), UNION the static host tool
62
+ * rows, MINUS the model/session wiring the reviewer needs. With
63
+ * `keepPluginRows` (explicit opt-in to study the host plugin face itself)
64
+ * the enumeration is skipped and only the static tool lockdown remains.
65
+ */
66
+ function buildReviewOverlayYaml(pluginRows, { keepPluginRows }) {
67
+ const disabled = new Set(keepPluginRows ? [] : pluginRows)
68
+ for (const row of REVIEW_DISABLED_TOOL_ROWS) disabled.add(row)
69
+ for (const row of REVIEW_REQUIRED_ROWS) disabled.delete(row)
70
+ return overlayDisableRows([...disabled])
71
+ }
72
+
73
+ /** Resolve and validate the compiled dsh CLI entry point. */
74
+ export function resolveDshCli(dshRepoDir) {
75
+ const repoDir = resolve(dshRepoDir)
76
+ const cli = join(repoDir, ...CLI_RELATIVE_PATH.split(/[\\/]/))
77
+ if (!existsSync(cli)) {
78
+ throw new Error(`no compiled dsh CLI at '${cli}' (build deepseek-harness first)`)
79
+ }
80
+ return cli
81
+ }
82
+
83
+ /** The CLI entry for an executor: explicit cliPath (C6 chain result) wins;
84
+ * otherwise fall back to the legacy repo form. Neither being set is a caller
85
+ * bug the CLI bins already catch — this guard serves direct API consumers. */
86
+ function executorCli(options) {
87
+ if (options.cliPath !== undefined) return resolve(options.cliPath)
88
+ if (options.dshRepoDir !== undefined) return resolveDshCli(options.dshRepoDir)
89
+ throw new Error('review adapter needs a CLI location: pass cliPath (C6 chain result) or dshRepoDir')
90
+ }
91
+
92
+ /**
93
+ * Create an executor compatible with executeReviewExperiment.
94
+ *
95
+ * The executor boots a blank review environment in an isolated DSH_HOME
96
+ * (staging/teardown mechanics shared with the behavior runner via
97
+ * sandbox.mjs): the staged profile's every out-of-tree plugin row and every
98
+ * host model-facing tool row is disabled via overlay (blank =
99
+ * dsh-base/dsh-headless templates + model wiring, nothing else — regardless
100
+ * of what the host profile carries), and the tool boundary is validated
101
+ * after the run.
102
+ *
103
+ * @param {object} options
104
+ * @param {string} [options.profile='headless'] - the review profile (host
105
+ * templates + whatever the host machine carries; plugin rows are disabled
106
+ * by the overlay anyway).
107
+ * @param {Set<string>} [options.allowedTools] - tool names permitted in the reviewer's session (default: empty).
108
+ * @param {boolean} [options.keepPluginRows=false] - opt back into the host
109
+ * profile's plugin face (e.g. to review a plugin's own gate behavior);
110
+ * only the static tool lockdown remains.
111
+ */
112
+ export function createDshHeadlessReviewExecutor(options) {
113
+ const cli = executorCli(options)
114
+ const profile = options.profile ?? 'headless'
115
+ if (typeof profile !== 'string' || profile.length === 0) {
116
+ throw new TypeError('dsh review adapter requires a profile')
117
+ }
118
+ const timeoutMs = options.timeoutMs ?? 300_000
119
+ const allowedTools = options.allowedTools ?? new Set()
120
+ const keepPluginRows = options.keepPluginRows ?? false
121
+
122
+ return async function executeWithDsh(task) {
123
+ // A fresh process alone is not enough: dsh also stores settings, titles,
124
+ // and sessions below DSH_HOME. Reuse the behavior harness's sandbox
125
+ // (sandbox.mjs) so every reviewer receives an isolated runtime state
126
+ // while retaining the selected profile's model config and plugin links.
127
+ const runDir = mkdtempSync(join(tmpdir(), 'dsh-review-'))
128
+ const dshHome = join(runDir, 'dsh-home')
129
+ stageSandboxHome(options.dshHome ?? resolveRealDshHome(), dshHome, profile)
130
+ // Blank environment by DEFAULT (review-blank-environment TODO): disable
131
+ // every row the staged profile composes beyond the host templates, so a
132
+ // host profile carrying out-of-tree plugins (gates included) can no
133
+ // longer steer or crash the reviewer. `keepPluginRows` opts back into
134
+ // the host plugin face deliberately. Enumeration happens on the STAGED
135
+ // copy, after staging — the staged home is what actually boots.
136
+ const overlayPath = join(runDir, 'review-overlay.yml')
137
+ writeFileSync(overlayPath, buildReviewOverlayYaml(
138
+ stagedPluginRows(dshHome, profile),
139
+ { keepPluginRows },
140
+ ), 'utf8')
141
+
142
+ try {
143
+ const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
144
+ cli,
145
+ cliArgs: ['--profile', profile, '--patch', overlayPath, task],
146
+ cwd: options.cwd ?? runDir,
147
+ env: {
148
+ ...process.env,
149
+ ...options.env,
150
+ DSH_HOME: dshHome,
151
+ DSH_TELEMETRY_DISABLED: '1',
152
+ },
153
+ timeoutMs,
154
+ })
155
+
156
+ const result = { stdout, stderr, exitCode, timedOut, profile, cli, runDir }
157
+ if (exitCode !== 0 || timedOut) {
158
+ const error = new Error(`dsh reviewer exited with code ${exitCode}${timedOut ? ' after timeout' : ''}`)
159
+ error.result = result
160
+ throw error
161
+ }
162
+
163
+ // Post-run tool boundary check: parse the session
164
+ // trace, verify no unexpected tools were mounted in the reviewer's
165
+ // session, fail the run on violation. An absent session log skips
166
+ // the check gracefully (accepted fail-open).
167
+ // Validation inspects the main session only (buildTrace selects
168
+ // non-subagent logs); plugin tools leaking in a subagent session
169
+ // would not be caught irrelevant in review where the overlay
170
+ // disables every subagent tool row.
171
+ const trace = loadTraceDir(join(dshHome, 'sessions'))
172
+ if (trace) {
173
+ const validation = validateToolBoundary(trace, { allowedTools })
174
+ result.toolValidation = validation
175
+ if (!validation.ok) {
176
+ // Attach evidence for the caller to persist (the adapter's
177
+ // runDir is ephemeral — removed by the finally block). The
178
+ // bin writes this to `.runs/<id>/tool-boundary-evidence.json`.
179
+ result.toolBoundaryEvidence = renderToolBoundaryEvidence(validation, { runDir, profile })
180
+ const boundaryError = new Error(
181
+ `tool boundary violation: unexpected tools [${validation.unexpected.join(', ')}]`,
182
+ )
183
+ boundaryError.result = result
184
+ throw boundaryError
185
+ }
186
+ // The ANSWER to the task, not the last message: stdout carries the
187
+ // headless CLI's final assistant message — whatever the reviewer
188
+ // said LAST. If any tail interaction intervened (a turn-close gate
189
+ // splice that slipped past the blank environment, an infra
190
+ // complaint), stdout holds that instead of the analysis. The
191
+ // trace's answerText (last assistant text before the first
192
+ // plugin-sourced injection) IS the analysis; stdout remains the
193
+ // fallback for trace-less runs.
194
+ result.answer = trace.answerText !== '' ? trace.answerText : stdout
195
+ } else {
196
+ result.answer = stdout
197
+ }
198
+
199
+ return result
200
+ } finally {
201
+ teardownSandbox(runDir, { keep: process.env.DSH_REVIEW_KEEP_TMP === '1' })
202
+ }
203
+ }
204
+ }
205
+
206
+ /** Run a review experiment through fresh dsh headless processes. */
207
+ export async function runDshReviewExperiment(experiment, options) {
208
+ const executor = createDshHeadlessReviewExecutor(options)
209
+ return executeReviewExperiment(experiment, executor, { runs: options.runs })
210
+ }