@catheadowl/dsh-eval 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,10 +1,17 @@
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.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/index.mjs",
8
+ "exports": {
9
+ ".": "./src/index.mjs",
10
+ "./experimental": "./src/experimental.mjs",
11
+ "./bin/dsh-eval": "./bin/dsh-eval.mjs",
12
+ "./bin/dsh-review": "./bin/dsh-review.mjs",
13
+ "./package.json": "./package.json"
14
+ },
8
15
  "peerDependencies": {
9
16
  "@deepseek-ai/dsh-llm": "*"
10
17
  },
@@ -20,12 +27,15 @@
20
27
  "src/",
21
28
  "docs/",
22
29
  "README.md",
30
+ "README.zh.md",
23
31
  "LICENSE"
24
32
  ],
25
33
  "scripts": {
26
34
  "verify:publish": "node scripts/verify-publish-readiness.mjs",
27
35
  "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"
36
+ "verify:experimental": "node scripts/verify-experimental-face.mjs",
37
+ "verify:readme-i18n": "node scripts/verify-readme-i18n.mjs",
38
+ "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"
30
40
  }
31
41
  }
@@ -1,192 +1,157 @@
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, 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
+ * 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.
21
+ */
22
+ const REVIEW_DISABLED_TOOL_ROWS = [
23
+ 'tool-bash',
24
+ 'tool-pwsh',
25
+ 'tool-jobs',
26
+ 'tool-fs',
27
+ 'tool-fs-search',
28
+ 'tool-skill',
29
+ 'tool-todo',
30
+ 'tool-goal',
31
+ 'tool-ralph',
32
+ 'tool-str-replace-editor',
33
+ 'tool-web',
34
+ 'tool-subagent-control',
35
+ 'tool-subagent-list-agents',
36
+ 'tool-subagent',
37
+ 'tool-subagent-fork',
38
+ 'tool-subagent-report',
39
+ 'tool-workflow',
40
+ ]
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
+ }
46
+
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
55
+ }
56
+
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. */
60
+ 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')
64
+ }
65
+
66
+ /**
67
+ * Create an executor compatible with executeReviewExperiment.
68
+ *
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.
75
+ *
76
+ * @param {object} options
77
+ * @param {string} [options.profile='headless'] - the sterile review profile.
78
+ * @param {Set<string>} [options.allowedTools] - tool names permitted in the reviewer's session (default: empty).
79
+ */
80
+ export function createDshHeadlessReviewExecutor(options) {
81
+ const cli = executorCli(options)
82
+ const profile = options.profile ?? 'headless'
83
+ if (typeof profile !== 'string' || profile.length === 0) {
84
+ throw new TypeError('dsh review adapter requires a profile')
85
+ }
86
+ const timeoutMs = options.timeoutMs ?? 300_000
87
+ const allowedTools = options.allowedTools ?? new Set()
88
+
89
+ return async function executeWithDsh(task) {
90
+ // A fresh process alone is not enough: dsh also stores settings, titles,
91
+ // and sessions below DSH_HOME. Reuse the behavior harness's sandbox
92
+ // (sandbox.mjs) so every reviewer receives an isolated runtime state
93
+ // while retaining the selected profile's model config and plugin links.
94
+ const runDir = mkdtempSync(join(tmpdir(), 'dsh-review-'))
95
+ const dshHome = join(runDir, 'dsh-home')
96
+ const overlayPath = join(runDir, 'review-overlay.yml')
97
+ writeFileSync(overlayPath, buildReviewOverlayYaml(), 'utf8')
98
+ stageSandboxHome(options.dshHome ?? resolveRealDshHome(), dshHome, profile)
99
+
100
+ try {
101
+ const { stdout, stderr, exitCode, timedOut } = await spawnHeadlessDsh({
102
+ cli,
103
+ cliArgs: ['--profile', profile, '--patch', overlayPath, task],
104
+ cwd: options.cwd ?? runDir,
105
+ env: {
106
+ ...process.env,
107
+ ...options.env,
108
+ DSH_HOME: dshHome,
109
+ DSH_TELEMETRY_DISABLED: '1',
110
+ },
111
+ timeoutMs,
112
+ })
113
+
114
+ const result = { stdout, stderr, exitCode, timedOut, profile, cli, runDir }
115
+ if (exitCode !== 0 || timedOut) {
116
+ const error = new Error(`dsh reviewer exited with code ${exitCode}${timedOut ? ' after timeout' : ''}`)
117
+ error.result = result
118
+ throw error
119
+ }
120
+
121
+ // Post-run tool boundary check: parse the session
122
+ // 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).
125
+ // Validation inspects the main session only (buildTrace selects
126
+ // non-subagent logs); plugin tools leaking in a subagent session
127
+ // would not be caught irrelevant in review where the overlay
128
+ // 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
+ }
144
+ }
145
+
146
+ return result
147
+ } finally {
148
+ teardownSandbox(runDir, { keep: process.env.DSH_REVIEW_KEEP_TMP === '1' })
149
+ }
150
+ }
151
+ }
152
+
153
+ /** Run a review experiment through fresh dsh headless processes. */
154
+ export async function runDshReviewExperiment(experiment, options) {
155
+ const executor = createDshHeadlessReviewExecutor(options)
156
+ return executeReviewExperiment(experiment, executor, { runs: options.runs })
157
+ }
package/src/cli.mjs CHANGED
@@ -1,16 +1,15 @@
1
1
  /**
2
- * Host CLI resolution chain (release-plan C6 / spec host-checkout-resolution).
2
+ * Host CLI resolution chain.
3
3
  *
4
4
  * Locating the compiled dsh CLI follows the same two-layer model as package
5
5
  * imports: committed files carry no real host-checkout path — the machine's
6
- * resolution layer (node_modules, junction-built by the relink anchor tool)
7
- * absorbs it. Precedence, first hit wins:
6
+ * resolution layer (node_modules) absorbs it. Precedence, first hit wins:
8
7
  *
9
8
  * 1. explicit `--repo <dir>` flag — the documented escape hatch;
10
9
  * 2. resolution layer — `node_modules/@deepseek-ai/dsh/lib/bin.js`
11
10
  * (the CLI package's own bin target) reachable upward from startDir;
12
11
  * 3. config `repo` key (legacy) — kept working for existing checked-in
13
- * `dsh-eval.config.mjs` files until the repo split retires them.
12
+ * `dsh-eval.config.mjs` files.
14
13
  *
15
14
  * Every miss fails loud with a fingerprint and placeholder-only guidance —
16
15
  * no machine-specific example paths, no silent fallback to guessing.
@@ -29,10 +28,11 @@ const PACKAGED_CLI_PATH = join('node_modules', '@deepseek-ai', 'dsh', 'lib', 'bi
29
28
  export const NO_CLI_GUIDANCE = [
30
29
  'no dsh CLI found. In order:',
31
30
  " 1) pass --repo <host-checkout> explicitly;",
32
- ' 2) or make the resolution layer provide it: node_modules/@deepseek-ai/dsh/lib/bin.js',
33
- ' (run the repo relink script to (re)build the junction tree from DSH_REPO,',
34
- ' then build the host checkout if lib/ is missing);',
35
- ' 3) or set repo in dsh-eval.config.mjs (legacy, retired at repo split).',
31
+ ' 2) or provide the CLI through the resolution layer:',
32
+ ' node_modules/@deepseek-ai/dsh/lib/bin.js install the @deepseek-ai/dsh',
33
+ ' package (or link a built deepseek-harness checkout into node_modules',
34
+ ' and build it so lib/bin.js exists);',
35
+ ' 3) or set repo in dsh-eval.config.mjs (legacy).',
36
36
  ].join('\n')
37
37
 
38
38
  /** Validate a repo-style candidate: return the CLI path or undefined. */
package/src/config.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared `dsh-eval.config.mjs` discovery and loading (EVAL-008).
2
+ * Shared `dsh-eval.config.mjs` discovery and loading.
3
3
  *
4
4
  * Both CLIs (`dsh-eval`, `dsh-review`) repeat `--profile/--repo` wiring in
5
5
  * every consumer's package scripts. A per-package config file removes that
package/src/discovery.mjs CHANGED
@@ -39,6 +39,23 @@ export function discoverFiles(path, suffix, out = []) {
39
39
  return out
40
40
  }
41
41
 
42
+ /**
43
+ * Validate a `disableRows` declaration (case-level or ad-hoc): an array of
44
+ * non-empty loader row id strings. An EMPTY array is legal and means
45
+ * "disable nothing, explicitly" — it overrides a `disableRows` default
46
+ * from `dsh-eval.config.mjs`. Throws with `label` context. Shared by
47
+ * `validateEvalCase` (load time) and `runEvalCase` (execution time) so both
48
+ * report the identical message.
49
+ * @param {unknown} disableRows - the value to validate.
50
+ * @param {string} label - error-message context (e.g. `case '<id>'`).
51
+ */
52
+ export function validateDisableRows(disableRows, label) {
53
+ if (!Array.isArray(disableRows)
54
+ || disableRows.some(row => typeof row !== 'string' || row === '')) {
55
+ throw new Error(`${label}: disableRows must be a string[] of loader row ids (empty = explicit none, overriding config; got '${JSON.stringify(disableRows)}')`)
56
+ }
57
+ }
58
+
42
59
  /**
43
60
  * Validate one eval case's shape. Throws with a descriptive message on
44
61
  * the first violation found. Checks:
@@ -51,6 +68,10 @@ export function discoverFiles(path, suffix, out = []) {
51
68
  * "disable nothing, explicitly" — it overrides a `disableRows` default
52
69
  * from `dsh-eval.config.mjs`, which is how gate-interaction cases opt
53
70
  * back in inside a package that disables the gate row by default.
71
+ * - `rowConfig` (if present) maps loader row ids to config objects whose
72
+ * leaf values are scalars or arrays of scalars (see `validateRowConfig`).
73
+ * The overlay REPLACES the row's whole config — restate any keys the row
74
+ * needs, not just the ones being changed.
54
75
  * - `expect` is an array; every element has `describe` (string) and `check` (function).
55
76
  * - mock mode requires a `script` with `steps` array.
56
77
  *
@@ -74,10 +95,10 @@ export function validateEvalCase(evalCase, file) {
74
95
  throw new Error(`${file}: case '${evalCase.id}': the 'gates' field was removed — declare disableRows: ['gates'] instead`)
75
96
  }
76
97
  if (evalCase.disableRows !== undefined) {
77
- if (!Array.isArray(evalCase.disableRows)
78
- || evalCase.disableRows.some(row => typeof row !== 'string' || row === '')) {
79
- throw new Error(`${file}: case '${evalCase.id}': disableRows must be a string[] of loader row ids (empty = explicit none, overriding config; got '${JSON.stringify(evalCase.disableRows)}')`)
80
- }
98
+ validateDisableRows(evalCase.disableRows, `${file}: case '${evalCase.id}'`)
99
+ }
100
+ if (evalCase.rowConfig !== undefined) {
101
+ validateRowConfig(evalCase.rowConfig, `case '${evalCase.id}'`)
81
102
  }
82
103
  if (!Array.isArray(evalCase.expect)) {
83
104
  throw new Error(`${file}: case '${evalCase.id}': expect must be a Matcher[]`)
@@ -95,6 +116,36 @@ export function validateEvalCase(evalCase, file) {
95
116
  }
96
117
  }
97
118
 
119
+ /**
120
+ * Validate a `rowConfig` mapping (case-level or ad-hoc): keys are loader row
121
+ * ids, values are config objects whose leaf values must be scalars (string /
122
+ * number / boolean) or arrays of scalars. Nested objects are rejected — the
123
+ * overlay emitter only handles flat config keys. Throws with `label` context.
124
+ * @param {unknown} rowConfig - the value to validate.
125
+ * @param {string} label - error-message context (e.g. `case '<id>'`).
126
+ */
127
+ export function validateRowConfig(rowConfig, label) {
128
+ if (rowConfig === null || typeof rowConfig !== 'object' || Array.isArray(rowConfig)) {
129
+ throw new Error(`${label}: rowConfig must be an object mapping row ids to config objects (got '${JSON.stringify(rowConfig)}')`)
130
+ }
131
+ for (const [rowId, config] of Object.entries(rowConfig)) {
132
+ if (rowId === '') throw new Error(`${label}: rowConfig row id must be a non-empty string`)
133
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
134
+ throw new Error(`${label}: rowConfig['${rowId}'] must be a config object (got '${JSON.stringify(config)}')`)
135
+ }
136
+ for (const [key, value] of Object.entries(config)) {
137
+ if (key === '') throw new Error(`${label}: rowConfig['${rowId}'] has an empty config key`)
138
+ if (Array.isArray(value)) {
139
+ if (value.some(item => item === null || typeof item === 'object')) {
140
+ throw new Error(`${label}: rowConfig['${rowId}']['${key}'] must be an array of scalars`)
141
+ }
142
+ } else if (value === null || typeof value === 'object') {
143
+ throw new Error(`${label}: rowConfig['${rowId}']['${key}'] must be a scalar or scalar array (nested objects are not supported)`)
144
+ }
145
+ }
146
+ }
147
+ }
148
+
98
149
  /**
99
150
  * Detect duplicate case ids across a flat case list. Throws on the first
100
151
  * duplicate found, naming both source files.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Escape-hatch entry: mechanism primitives for ad-hoc diagnostic scripts and
3
+ * self-built execution faces. Reachable as `<pkg>/experimental`.
4
+ *
5
+ * Contract: NO compatibility promise — symbols here may change or move in
6
+ * any minor release. The stable surface for eval/review case authors is the
7
+ * package root entry (src/index.mjs); import from here only when you are
8
+ * building your own runner/driver and accept the follow-up cost.
9
+ */
10
+
11
+ // --- host CLI resolution (modern three-segment chain) ---
12
+ export { resolveDshCliChain } from './cli.mjs'
13
+
14
+ // --- sandbox / overlay mechanism ---
15
+ export { stageProfileStore } from './sandbox.mjs'
16
+ export { buildOverlayYaml, overlayDisableRows } from './overlay.mjs'
17
+
18
+ // --- session-trace primitives ---
19
+ export { parseSessionLog, buildTrace, loadTraceDir } from './trace.mjs'
20
+
21
+ // --- review experiment execution layer ---
22
+ export {
23
+ executeReviewExperiment,
24
+ materializeReviewExperiment,
25
+ renderObservationSections,
26
+ OBSERVATIONS_PLACEHOLDER,
27
+ } from './experiment/review.mjs'
28
+
29
+ // --- dsh review executors ---
30
+ export {
31
+ createDshHeadlessReviewExecutor,
32
+ runDshReviewExperiment,
33
+ } from './adapters/dsh/review.mjs'
34
+
35
+ // --- tool-boundary validation ---
36
+ export {
37
+ validateToolBoundary,
38
+ renderToolBoundaryEvidence,
39
+ } from './tool-validation.mjs'