@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.
@@ -0,0 +1,71 @@
1
+ ---
2
+ description: 安装与宿主接线——dsh-llm peer 的三形解析结局与 junction 步骤、构建 CLI 与 profile/凭证/spawn 三类运行前置
3
+ ---
4
+
5
+ # 安装与宿主接线
6
+
7
+ `@catheadowl/dsh-eval` 是 dsh 生态的开发期工具:它驱动真实 dsh headless 会话来评测 dsh 插件,因此对宿主有三类依赖——包管理面(peer)、运行面(构建好的 dsh CLI)、环境面(profile 与凭证)。
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i -D @catheadowl/dsh-eval
13
+ # 或 pnpm add -D @catheadowl/dsh-eval
14
+ ```
15
+
16
+ 装好后 `node_modules/.bin` 里出现两个 CLI:`dsh-eval`(behavior case)与 `dsh-review`(comprehension review)。
17
+
18
+ ## peer 依赖:`@deepseek-ai/dsh-llm` 的接线契约
19
+
20
+ 本包声明 `peerDependencies: { "@deepseek-ai/dsh-llm": "*" }`——运行时由 dsh 宿主生态提供,包自身不携带。消费态解析有**三形结局**:
21
+
22
+ 1. **缺包形**(pnpm `file:` 安装态):mock case 报 `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-llm'`,`imported from` 锚在 `node_modules/.pnpm/@catheadowl+dsh-eval.../` 内——上溯链止于虚拟 store 是本契约的特征指纹,不是本包缺陷。
23
+ 2. **古董 peer 形**(npm 安装态,更隐蔽):npm ≥7 会**自动安装 peerDependencies**,而 `@deepseek-ai/dsh-llm` 的 registry `latest` dist-tag 停在 `0.0.1-rc.1`——与宿主现行 API 不兼容。症状:boot 报 `dsh: UNKNOWN: registration.adapter.prepareCall is not a function`,case exit 1、无 assistant 文本。装了错版本比没装更难排查。
24
+ 3. **正确形**:把 `node_modules/@deepseek-ai/dsh-llm` 替换为指向宿主 checkout 内对应包的目录链接(Windows junction),或安装时 `--legacy-peer-deps` 阻止自动装后再接。
25
+
26
+ **接线步骤**(消费者根目录,Windows):
27
+
28
+ ```powershell
29
+ Remove-Item -Recurse -Force node_modules\@deepseek-ai\dsh-llm # 若被自动装了
30
+ New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-llm `
31
+ -Target <dsh checkout>\apps\cli\node_modules\@deepseek-ai\dsh-llm
32
+ ```
33
+
34
+ **自诊断**:
35
+
36
+ ```bash
37
+ node -e "import('@deepseek-ai/dsh-llm').then(() => console.log('ok'), e => console.log(e.code))"
38
+ node -e "console.log(require('./node_modules/@deepseek-ai/dsh-llm/package.json').version)"
39
+ ```
40
+
41
+ 第一条打印 `ok` = 解析链通;第二条核对版本——若为 `0.0.1-rc.1` 即 registry 古董副本,按上节替换。接线是机器相关的部署前提,不入库(gitignore 或本地脚本承接)。
42
+
43
+ ## 运行面:构建好的 dsh CLI
44
+
45
+ dsh CLI 的定位按以下顺序,先中先得:
46
+
47
+ 1. **显式 flag**:`--repo <host-checkout>`(检出须已构建,`apps/cli/lib/bin.js` 存在);
48
+ 2. **解析层**:`node_modules/@deepseek-ai/dsh/lib/bin.js`(开发态由 junction 指到宿主检出,可用 relink 脚本从机器级 `DSH_REPO` 锚点重建;消费态由安装树提供);
49
+ 3. **config `repo` 键**:legacy,已从各包入库 config 退役。
50
+
51
+ 三层全缺时 fail-loud(报错含占位符修法指引)。自诊断:
52
+
53
+ ```bash
54
+ node -e "console.log(require('fs').existsSync('node_modules/@deepseek-ai/dsh/lib/bin.js'))"
55
+ ```
56
+
57
+ `false` = 解析层缺 CLI:先跑 relink 重建 junction;仍 `false` 则宿主检出未构建(先构建宿主)。behavior 与 review 的真实运行都从定位到的 CLI spawn dsh 本体。
58
+
59
+ ## 环境面:profile 与插件安装
60
+
61
+ - 被测插件须已装进所选 profile:`dsh plugin --profile <profile> add <插件目录>`;
62
+ - 每条 behavior case 在隔离的临时 `DSH_HOME` 与 workspace 中启动 dsh,通过 `--patch` 把 session JSONL 定向到本次 run——不污染真实 profile store;
63
+ - review 使用专用 sterile profile(详见 [review.md](review.md))。
64
+
65
+ ## 凭证
66
+
67
+ real 层的**模型凭证由 dsh 自行解析**——spawn 的是 dsh 本体,env 原样透传,真实 home 的托管凭证文档(`.credentials.yaml`)会被复制进暂存 home;**你的 dsh 能正常跑,real case 就能跑**。本包不做任何凭证配置,只在启动前做存在性探测(env 或托管文档任一可见)以决定 real case 是否 auto-skip。mock 与 review dry-run 完全不需要凭证。
68
+
69
+ ## spawn 要求
70
+
71
+ real case 会以管道 stdio spawn 子 dsh CLI(headless 会话、子代理子运行时),因此需要一个能 spawn 子进程的运行面——受限沙箱里可能被拒(`spawn EPERM`),此时在宿主侧终端 / CI 跑即可。mock 与 review dry-run 不 spawn 子 CLI,无此约束。
@@ -0,0 +1,39 @@
1
+ ---
2
+ description: real 意图 case 规约——何时写的触发表、断言面最小化(toolCalled/toolCallArgs/inspect 守卫)、fixture 与门禁交互规避、凭证 auto-skip 与 CI 语义
3
+ ---
4
+
5
+ # Real 意图 case 规约
6
+
7
+ behavior real 断言「自然语言意图 → 工具选择与参数路由」,mock 断言「工具管线与写入 round-trip」:两层互补,不互相替代。
8
+
9
+ ## 何时写(触发表)
10
+
11
+ 满足任一条即值得写一条 real case:
12
+
13
+ 1. 注册了模型可见工具(happy path ≥1);
14
+ 2. 目标存在等价手工路径(可对照);
15
+ 3. 描述 steering 变更(模型可见输入面变了);
16
+ 4. 分支由数据面状态分流(成对 case,各断一个分支);
17
+ 5. 拒绝路径面向模型消费(remedy 委派边界——模型要读懂失败并转述);
18
+ 6. 有误触发风险(负向 `toolNotCalled` case)。
19
+
20
+ > 触发表的完整论证(依据与失败启发集)属于通用的意图面 e2e 方法论;本表即其 dsh 承载面的规范版,自足使用。
21
+
22
+ ## 断言面(最小化)
23
+
24
+ - `toolCalled`(不是 `firstTool`——探索在前合法)+ `toolCallArgs` 子集(路由 payoff 在参数对)+ 语义关键时 `toolResultTextIncludes` 状态锚(如 `"status": "repaired"`);
25
+ - 不约束措辞与中间步骤——意图测试的模型措辞天然漂移,工具选择才是契约。
26
+
27
+ ## 守卫
28
+
29
+ - `inspect` 守结果面 + 反捏造(不重建旧路径、不凭空造文件),不管模型走什么中间路径;
30
+ - fixture 必须可区分(不同分支的 fixture 不能靠巧合区分);
31
+ - turn-close 门禁交互:见 [disablerows.md](disablerows.md)(case 级出口 `assistantTextIncludes`;splice 步骤与脚本本体的区分尚无框架级方案)。
32
+
33
+ ## CI 语义
34
+
35
+ - 无凭证 auto-skip;CI 门禁用 `--fail-on-skip` 防「根本没跑但成功」。
36
+
37
+ ## 实例
38
+
39
+ 「同一意图 × 数据面分流」成对设计的可模仿形态:一组意图集检查(review 通道断言理解)配一组同意图的行为四连——repair / discovery / no-evidence / oldpath-missing(behavior 通道断言执行)——同一意图在两个数据面上分流成对,互为回归网。
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: 已知问题——real case 在 staged home 的 REQUEST_EXTENSION 失败(嫌疑 plugin-package-inventory-deepseek)及 disableRows 兜底
3
+ ---
4
+
5
+ # 已知问题
6
+
7
+ ## real case 在 staged home 下 `REQUEST_EXTENSION` 失败(未解)
8
+
9
+ **症状**:real behavior case 在暂存临时 `DSH_HOME` 里,首个模型调用即报 `REQUEST_EXTENSION` 类失败;mock 不受影响。
10
+
11
+ **嫌疑**:宿主的 `plugin-package-inventory-deepseek` 插件 × staged 环境的包身份解析(staging 复制的 profile 让该插件的清单收集失败)。
12
+
13
+ **框架侧兜底**:case 或 config 声明 `disableRows: ['plugin-package-inventory-deepseek']` 按行禁用该插件(机制见 [disablerows.md](disablerows.md))。
14
+
15
+ **追踪**:上游修复落地即删本条(不留僵尸条目)。
@@ -0,0 +1,35 @@
1
+ ---
2
+ description: trace matcher 与 mock helper 全集——工具面/文本面/输入面断言语义(toolCalled 到 userMessageTextIncludes)与 toolCallStep/textStep 脚本构件
3
+ ---
4
+
5
+ # Trace matchers 与 mock helpers
6
+
7
+ 全部从包根导入:`import { toolCalled, … } from '@catheadowl/dsh-eval'`。
8
+
9
+ 断言对象是 dsh session 事件投影(`src/trace.mjs` 的 `EvalTrace`),不只是「模型产出」:`requestHeaders` 投影模型被挂载的工具与 system prompt(输入面),`userMessages` 投影 user-role 的模型可见输入面(任务 prompt、插件 steer、注入上下文)——这让 mock 能断言插件的**驱动级 steer**,而不只断工具选择或最终文本。
10
+
11
+ ## 工具面
12
+
13
+ - `toolCalled(name)` / `toolNotCalled(name)`:工具被/未被调用(字符串或 RegExp);
14
+ - `firstTool(name)`:首个工具调用是它(慎用——探索在前合法,意图 case 通常用 `toolCalled`);
15
+ - `toolSequence(names)`:按序子序列;
16
+ - `toolCallArgs(name, subset | predicate)`:参数子集匹配(或谓词,收 parsed 与 raw);
17
+ - `toolResultFor(name)`:匹配的调用有结果配对;
18
+ - `toolResultIsError(name)` / `toolResultSucceeded(name)`:结果 `isError === true` / 不为 true;
19
+ - `toolResultTextIncludes(name, substring)`:结果文本含子串。
20
+
21
+ ## 文本面
22
+
23
+ - `finalTextIncludes(substring)` / `finalTextMatches(re)`:最后一个非空 assistant 文本(mock 层断言的是脚本终步的确定性文本);
24
+ - `assistantTextIncludes(substring)`:**任一** assistant 文本含子串——turn-close 门禁 splice 反馈步骤、`finalText*` 被截走时的 case 级出口。
25
+
26
+ ## 模型可见面(输入侧)
27
+
28
+ - `systemPromptIncludes(substring)`:组装后的 system prompt 含子串;
29
+ - `toolMounted(name)`:工具出现在某个 request/header 的挂载列表;
30
+ - `userMessageTextIncludes(source, substring)` / `userMessageTextExcludes(source, substring)`:按 `source` 过滤的 `user/message` 文本含/不含子串。`source` 用字符串/RegExp 匹配 `plugin` 名(如 steer 生产方),或谓词取整个 `source`——steer 在持久化日志里没有专名事件(`agent.steer()` 落为 `user/message`),区分靠 `source`(插件 steer 为 `{ kind: 'plugin', plugin: '<id>' }`,任务 prompt 为 `{ kind: 'user' }`)。
31
+
32
+ ## Mock script helpers
33
+
34
+ - `toolCallStep(name, args)`:一步「模型调工具」,结束于 tool-calls;
35
+ - `textStep(text)`:一步「模型说话」,结束于 stop(turn 收束)。
package/docs/report.md ADDED
@@ -0,0 +1,27 @@
1
+ ---
2
+ description: 机器可读报告结构——--format json / --report 的字段语义(环境锚点、per-case 三态 status、派生 summary 与退出码)
3
+ ---
4
+
5
+ # 机器可读报告
6
+
7
+ `--format json`:stdout 只输出一个 JSON 报告对象(过程与失败明细转 stderr),供 CI / 多插件聚合消费;`--report <file>`:在任一格式下额外把同一报告对象写入文件。报告构造在 `src/report.mjs`:
8
+
9
+ ```jsonc
10
+ {
11
+ "tool": "dsh-eval",
12
+ "profile": "headless", "repo": "<absolute harness checkout>",
13
+ "mode": "mock", "failOnSkip": false,
14
+ "startedAt": "…", "finishedAt": "…",
15
+ "summary": { "selected": 1, "passed": 1, "failed": 0, "skipped": 0 },
16
+ "results": [
17
+ {
18
+ "id": "…", "file": "…", "mode": "mock", "status": "pass",
19
+ "exitCode": 0, "timedOut": false, "durationMs": 5000
20
+ // fail 时另有 failures[]、artifactsDir;skip 时另有 skipReason;
21
+ // case 文件加载失败/重复 id 这类文件级失败也进 results(无 mode 字段)
22
+ }
23
+ ]
24
+ }
25
+ ```
26
+
27
+ status 取值 `pass | fail | skip`;退出码与文本格式完全一致(同一 `reportExitCode` 派生)。默认 `--format text` 输出为人类可读进度行 + 汇总行。
package/docs/review.md ADDED
@@ -0,0 +1,77 @@
1
+ ---
2
+ description: comprehension review 指南——defineReviewExperiment 实验定义、sterile profile 盲评运行、.runs 产物与 review-report 判读模板、六条固化规则
3
+ ---
4
+
5
+ # Comprehension review
6
+
7
+ 理解评审回答 behavior matcher 无法回答的问题:**一个 fresh model 能否从插件输出理解含义和下一步**。「缺少应有提示」「字段容易误读」是设计缺口,不是固定字符串回归——刻意不用 trace matcher 自动化;反过来,行为层不负责评价自由文本设计。
8
+
9
+ ## 实验定义
10
+
11
+ `<plugin>/eval/comprehension/<name>.review.mjs`:
12
+
13
+ ```js
14
+ import { readFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { defineReviewExperiment } from '@catheadowl/dsh-eval'
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url))
20
+
21
+ export default defineReviewExperiment({
22
+ id: 'my-output-comprehension',
23
+ summary: 'Can a fresh model infer the next action?',
24
+ prompt: readFileSync(join(here, 'prompt.md'), 'utf8'),
25
+ rubric: join(here, 'rubric.md'),
26
+ defaultRuns: 3,
27
+ async observe() {
28
+ const liveOutput = await projectFrozenFixtureWithCurrentBuild()
29
+ return [{
30
+ heading: 'Scenarios',
31
+ entries: [{ heading: 'case-1', call: { path: 'x' }, json: liveOutput }],
32
+ }]
33
+ },
34
+ })
35
+ ```
36
+
37
+ 抽象层公开 `defineReviewExperiment`、`materializeReviewExperiment`、`executeReviewExperiment` 与标准 observation renderer(`renderObservationSections`;prompt 里的观测占位符常量是 `OBSERVATIONS_PLACEHOLDER`,必须恰好出现一次)。自定义 executor 只需实现 `(task, context) => result`;接入其他 agent runtime 不需要改试验定义。
38
+
39
+ ## CLI
40
+
41
+ ```bash
42
+ # dry-run 不要求 profile 或已构建 dsh CLI
43
+ dsh-review --dry-run <experiment file or directory>
44
+
45
+ dsh-review \
46
+ --profile <sterile-profile> \
47
+ --repo <deepseek-harness checkout> \
48
+ [--runs 5] [--timeout 300000] \
49
+ <experiment file or directory>
50
+ ```
51
+
52
+ `--profile`/`--repo` 可来自 `dsh-eval.config.mjs`(见 README),flags 覆盖 config。
53
+
54
+ ## sterile profile 与工具边界
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`)。
57
+
58
+ > **运维前提**:profile staging 在真实 home 已有同名 profile 时**原样复制**(含已安装插件与 patch 层)。若本机 `headless` profile 装过树外插件,暂存后的 profile **不是无菌的**——工具边界校验会当场 fail-loud(这是设计的正确行为)。保持无菌:删掉本机 `headless` profile 让 boot 重建出厂模板,或指定一个确认无插件的 profile。
59
+
60
+ ## 产物
61
+
62
+ 落在 review 文件旁的 `.runs/<experiment id>/`:
63
+
64
+ - `observations.md`:本次实时物化的可见证据;
65
+ - `task.txt`:实际发给每位 reviewer 的完整任务;
66
+ - `run-N.txt` / stderr / error:各次独立运行;
67
+ - `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)。
69
+
70
+ ## 从早期 eval 固化下来的六条规则
71
+
72
+ 1. **冻结输入,实时投影输出。** fixture 保存 raw SDK result、合成知识库或调用参数;`observe()` 必须调用当前构建产物。不要提交一份会随实现漂移的 projected-output 快照。
73
+ 2. **盲 prompt 与隐藏 rubric 分离。** `prompt.md` 只能含问题和 `{{EVAL_OBSERVATIONS}}`;答案键、预期 next action、intentional design 只在 `rubric.md`。
74
+ 3. **一次物化,多次独立评审。** 同一批运行共享完全相同的 task,避免把 fixture 波动误判成模型分歧;每次调用新的 headless 进程和隔离的临时 `DSH_HOME`,但暂存所选 profile 的配置与依赖链接。
75
+ 4. **主动标准化非语义噪声。** 临时绝对路径、时间戳等应在 plugin 的 observation projection 中替换或移除,同时保留真实字段名与语义。
76
+ 5. **显式登记 intentional design。** reviewer 提出的 red flag 只有不在该清单中时才是新发现,避免反复争论已接受取舍。
77
+ 6. **生成物不做 SSOT。** `.runs/` 只用于当次人工审阅,gitignore 使用无路径前缀的 `.runs/`,覆盖任意深度。
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@catheadowl/dsh-eval",
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",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.mjs",
8
+ "peerDependencies": {
9
+ "@deepseek-ai/dsh-llm": "*"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "bin": {
15
+ "dsh-eval": "bin/dsh-eval.mjs",
16
+ "dsh-review": "bin/dsh-review.mjs"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "src/",
21
+ "docs/",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "verify:publish": "node scripts/verify-publish-readiness.mjs",
27
+ "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"
30
+ }
31
+ }
@@ -0,0 +1,7 @@
1
+ /** Public dsh review adapter surface. */
2
+
3
+ export {
4
+ createDshHeadlessReviewExecutor,
5
+ resolveDshCli,
6
+ runDshReviewExperiment,
7
+ } from './review.mjs'
@@ -0,0 +1,192 @@
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
+ }