@vibe-agent-toolkit/agent-skills 0.2.0-rc.2 → 0.2.0-rc.4

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.
Files changed (213) hide show
  1. package/dist/files-config.d.ts +34 -1
  2. package/dist/files-config.d.ts.map +1 -1
  3. package/dist/files-config.js +280 -36
  4. package/dist/files-config.js.map +1 -1
  5. package/dist/fs-attribution.d.ts +45 -0
  6. package/dist/fs-attribution.d.ts.map +1 -0
  7. package/dist/fs-attribution.js +57 -0
  8. package/dist/fs-attribution.js.map +1 -0
  9. package/dist/index.d.ts +6 -5
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +9 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/inventory/detectors/declared-but-missing.d.ts +1 -1
  14. package/dist/inventory/detectors/declared-but-missing.d.ts.map +1 -1
  15. package/dist/inventory/detectors/marketplace-source-missing.d.ts +1 -1
  16. package/dist/inventory/detectors/marketplace-source-missing.d.ts.map +1 -1
  17. package/dist/inventory/detectors/present-but-undeclared.d.ts +1 -1
  18. package/dist/inventory/detectors/present-but-undeclared.d.ts.map +1 -1
  19. package/dist/inventory/detectors/reference-target-missing.d.ts +1 -1
  20. package/dist/inventory/detectors/reference-target-missing.d.ts.map +1 -1
  21. package/dist/inventory/index.d.ts +1 -1
  22. package/dist/inventory/index.d.ts.map +1 -1
  23. package/dist/inventory/index.js +1 -1
  24. package/dist/inventory/index.js.map +1 -1
  25. package/dist/inventory/serialize.d.ts +4 -4
  26. package/dist/inventory/serialize.d.ts.map +1 -1
  27. package/dist/inventory/serialize.js +6 -6
  28. package/dist/inventory/serialize.js.map +1 -1
  29. package/dist/plugin-distribution-layout.d.ts.map +1 -1
  30. package/dist/plugin-distribution-layout.js +3 -1
  31. package/dist/plugin-distribution-layout.js.map +1 -1
  32. package/dist/post-build-checks.d.ts +1 -1
  33. package/dist/post-build-checks.d.ts.map +1 -1
  34. package/dist/post-build-checks.js.map +1 -1
  35. package/dist/projection/skill-extent.d.ts +247 -0
  36. package/dist/projection/skill-extent.d.ts.map +1 -0
  37. package/dist/projection/skill-extent.js +558 -0
  38. package/dist/projection/skill-extent.js.map +1 -0
  39. package/dist/schemas/agent-skill-frontmatter.js +1 -1
  40. package/dist/schemas/agent-skill-frontmatter.js.map +1 -1
  41. package/dist/skill-packager.d.ts +45 -7
  42. package/dist/skill-packager.d.ts.map +1 -1
  43. package/dist/skill-packager.js +123 -25
  44. package/dist/skill-packager.js.map +1 -1
  45. package/dist/skill-source/git-clone.d.ts +1 -1
  46. package/dist/skill-source/git-clone.d.ts.map +1 -1
  47. package/dist/skill-source/git-clone.js +10 -8
  48. package/dist/skill-source/git-clone.js.map +1 -1
  49. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  50. package/dist/skill-source/sources/url-source.js +2 -1
  51. package/dist/skill-source/sources/url-source.js.map +1 -1
  52. package/dist/skill-test/baseline-delta.d.ts +1575 -0
  53. package/dist/skill-test/baseline-delta.d.ts.map +1 -0
  54. package/dist/skill-test/baseline-delta.js +272 -0
  55. package/dist/skill-test/baseline-delta.js.map +1 -0
  56. package/dist/skill-test/baseline-integrity.d.ts +944 -0
  57. package/dist/skill-test/baseline-integrity.d.ts.map +1 -0
  58. package/dist/skill-test/baseline-integrity.js +3083 -0
  59. package/dist/skill-test/baseline-integrity.js.map +1 -0
  60. package/dist/skill-test/configure-writer.js +1 -1
  61. package/dist/skill-test/configure-writer.js.map +1 -1
  62. package/dist/skill-test/declared-env.d.ts.map +1 -1
  63. package/dist/skill-test/declared-env.js +2 -1
  64. package/dist/skill-test/declared-env.js.map +1 -1
  65. package/dist/skill-test/eval-executor.d.ts +20 -4
  66. package/dist/skill-test/eval-executor.d.ts.map +1 -1
  67. package/dist/skill-test/eval-executor.js +4 -4
  68. package/dist/skill-test/eval-executor.js.map +1 -1
  69. package/dist/skill-test/eval-fragment.d.ts +80 -6
  70. package/dist/skill-test/eval-fragment.d.ts.map +1 -1
  71. package/dist/skill-test/eval-fragment.js +61 -7
  72. package/dist/skill-test/eval-fragment.js.map +1 -1
  73. package/dist/skill-test/eval-grader.d.ts +1 -1
  74. package/dist/skill-test/eval-grader.d.ts.map +1 -1
  75. package/dist/skill-test/eval-grader.js +145 -8
  76. package/dist/skill-test/eval-grader.js.map +1 -1
  77. package/dist/skill-test/eval-inputs.d.ts +107 -6
  78. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  79. package/dist/skill-test/eval-inputs.js +173 -16
  80. package/dist/skill-test/eval-inputs.js.map +1 -1
  81. package/dist/skill-test/eval-lint.d.ts +13 -0
  82. package/dist/skill-test/eval-lint.d.ts.map +1 -1
  83. package/dist/skill-test/eval-lint.js +17 -4
  84. package/dist/skill-test/eval-lint.js.map +1 -1
  85. package/dist/skill-test/executor-prompt.d.ts +13 -5
  86. package/dist/skill-test/executor-prompt.d.ts.map +1 -1
  87. package/dist/skill-test/executor-prompt.js +8 -5
  88. package/dist/skill-test/executor-prompt.js.map +1 -1
  89. package/dist/skill-test/exit-codes.d.ts +53 -16
  90. package/dist/skill-test/exit-codes.d.ts.map +1 -1
  91. package/dist/skill-test/exit-codes.js +123 -40
  92. package/dist/skill-test/exit-codes.js.map +1 -1
  93. package/dist/skill-test/fragment-merge.d.ts +1 -18
  94. package/dist/skill-test/fragment-merge.d.ts.map +1 -1
  95. package/dist/skill-test/fragment-merge.js +63 -4
  96. package/dist/skill-test/fragment-merge.js.map +1 -1
  97. package/dist/skill-test/grader-prompt.d.ts +27 -7
  98. package/dist/skill-test/grader-prompt.d.ts.map +1 -1
  99. package/dist/skill-test/grader-prompt.js +253 -61
  100. package/dist/skill-test/grader-prompt.js.map +1 -1
  101. package/dist/skill-test/grader-text.d.ts +145 -0
  102. package/dist/skill-test/grader-text.d.ts.map +1 -0
  103. package/dist/skill-test/grader-text.js +410 -0
  104. package/dist/skill-test/grader-text.js.map +1 -0
  105. package/dist/skill-test/grading-adapter.d.ts +32 -0
  106. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  107. package/dist/skill-test/grading-adapter.js +50 -3
  108. package/dist/skill-test/grading-adapter.js.map +1 -1
  109. package/dist/skill-test/grading-schema.d.ts +160 -5
  110. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  111. package/dist/skill-test/grading-schema.js +66 -7
  112. package/dist/skill-test/grading-schema.js.map +1 -1
  113. package/dist/skill-test/index.d.ts +1 -1
  114. package/dist/skill-test/index.d.ts.map +1 -1
  115. package/dist/skill-test/index.js +1 -1
  116. package/dist/skill-test/index.js.map +1 -1
  117. package/dist/skill-test/lock.d.ts +56 -0
  118. package/dist/skill-test/lock.d.ts.map +1 -1
  119. package/dist/skill-test/lock.js +75 -2
  120. package/dist/skill-test/lock.js.map +1 -1
  121. package/dist/skill-test/pipeline.d.ts +16 -0
  122. package/dist/skill-test/pipeline.d.ts.map +1 -1
  123. package/dist/skill-test/pipeline.js +16 -9
  124. package/dist/skill-test/pipeline.js.map +1 -1
  125. package/dist/skill-test/preflight.d.ts +1 -1
  126. package/dist/skill-test/preflight.d.ts.map +1 -1
  127. package/dist/skill-test/preflight.js +59 -15
  128. package/dist/skill-test/preflight.js.map +1 -1
  129. package/dist/skill-test/run-harness.d.ts +578 -35
  130. package/dist/skill-test/run-harness.d.ts.map +1 -1
  131. package/dist/skill-test/run-harness.js +1281 -148
  132. package/dist/skill-test/run-harness.js.map +1 -1
  133. package/dist/skill-test/staging.d.ts +8 -2
  134. package/dist/skill-test/staging.d.ts.map +1 -1
  135. package/dist/skill-test/staging.js +8 -4
  136. package/dist/skill-test/staging.js.map +1 -1
  137. package/dist/skill-test/tool-eval-schema.d.ts +6 -6
  138. package/dist/test-input.d.ts +57 -6
  139. package/dist/test-input.d.ts.map +1 -1
  140. package/dist/test-input.js +35 -12
  141. package/dist/test-input.js.map +1 -1
  142. package/dist/validators/agent-instruction-presence.d.ts +1 -1
  143. package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
  144. package/dist/validators/agent-instruction-presence.js +2 -1
  145. package/dist/validators/agent-instruction-presence.js.map +1 -1
  146. package/dist/validators/bundled-resource-link-detection.d.ts +1 -1
  147. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  148. package/dist/validators/bundled-resource-link-detection.js +1 -1
  149. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  150. package/dist/validators/compat-detectors.d.ts +1 -1
  151. package/dist/validators/compat-detectors.d.ts.map +1 -1
  152. package/dist/validators/compat-detectors.js +1 -1
  153. package/dist/validators/compat-detectors.js.map +1 -1
  154. package/dist/validators/cross-skill-dependency-detection.d.ts +1 -1
  155. package/dist/validators/cross-skill-dependency-detection.d.ts.map +1 -1
  156. package/dist/validators/cross-skill-dependency-detection.js +1 -1
  157. package/dist/validators/cross-skill-dependency-detection.js.map +1 -1
  158. package/dist/validators/description-style-detection.d.ts +1 -1
  159. package/dist/validators/description-style-detection.d.ts.map +1 -1
  160. package/dist/validators/description-style-detection.js +1 -1
  161. package/dist/validators/description-style-detection.js.map +1 -1
  162. package/dist/validators/frontmatter-validation.d.ts +1 -1
  163. package/dist/validators/frontmatter-validation.d.ts.map +1 -1
  164. package/dist/validators/frontmatter-validation.js +1 -1
  165. package/dist/validators/frontmatter-validation.js.map +1 -1
  166. package/dist/validators/imperative-body-detection.d.ts +1 -1
  167. package/dist/validators/imperative-body-detection.d.ts.map +1 -1
  168. package/dist/validators/imperative-body-detection.js +1 -1
  169. package/dist/validators/imperative-body-detection.js.map +1 -1
  170. package/dist/validators/index.d.ts +1 -1
  171. package/dist/validators/index.d.ts.map +1 -1
  172. package/dist/validators/kebab-case-detection.d.ts +1 -1
  173. package/dist/validators/kebab-case-detection.d.ts.map +1 -1
  174. package/dist/validators/kebab-case-detection.js +1 -1
  175. package/dist/validators/kebab-case-detection.js.map +1 -1
  176. package/dist/validators/marketplace-validator.js +1 -1
  177. package/dist/validators/marketplace-validator.js.map +1 -1
  178. package/dist/validators/packaging-validator.d.ts +96 -7
  179. package/dist/validators/packaging-validator.d.ts.map +1 -1
  180. package/dist/validators/packaging-validator.js +148 -21
  181. package/dist/validators/packaging-validator.js.map +1 -1
  182. package/dist/validators/plugin-hosted-shape.d.ts +1 -1
  183. package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
  184. package/dist/validators/plugin-hosted-shape.js +1 -1
  185. package/dist/validators/plugin-hosted-shape.js.map +1 -1
  186. package/dist/validators/plugin-recommended-fields.d.ts +1 -1
  187. package/dist/validators/plugin-recommended-fields.d.ts.map +1 -1
  188. package/dist/validators/plugin-recommended-fields.js +1 -1
  189. package/dist/validators/plugin-recommended-fields.js.map +1 -1
  190. package/dist/validators/registry-validator.js +1 -1
  191. package/dist/validators/registry-validator.js.map +1 -1
  192. package/dist/validators/rule-engine/rule-engine.d.ts +1 -1
  193. package/dist/validators/rule-engine/rule-engine.d.ts.map +1 -1
  194. package/dist/validators/rule-engine/rule-engine.js +20 -9
  195. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  196. package/dist/validators/skill-validator.d.ts.map +1 -1
  197. package/dist/validators/skill-validator.js +21 -3
  198. package/dist/validators/skill-validator.js.map +1 -1
  199. package/dist/validators/types.d.ts +1 -1
  200. package/dist/validators/types.d.ts.map +1 -1
  201. package/dist/validators/unified-validator.js +1 -1
  202. package/dist/validators/unified-validator.js.map +1 -1
  203. package/dist/validators/validation-rules.d.ts +1 -1
  204. package/dist/validators/validation-rules.d.ts.map +1 -1
  205. package/dist/validators/walker-to-issues.d.ts +7 -3
  206. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  207. package/dist/validators/walker-to-issues.js +37 -8
  208. package/dist/validators/walker-to-issues.js.map +1 -1
  209. package/dist/walk-link-graph.d.ts +2 -1
  210. package/dist/walk-link-graph.d.ts.map +1 -1
  211. package/dist/walk-link-graph.js +37 -2
  212. package/dist/walk-link-graph.js.map +1 -1
  213. package/package.json +7 -7
@@ -11,16 +11,20 @@
11
11
  * → release lock → return result
12
12
  */
13
13
  import { randomBytes } from 'node:crypto';
14
- import { existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import { homedir } from 'node:os';
16
16
  import { basename, dirname } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
- import { getToolVersion, killAllActiveClaudeChildren, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, resolveAssetReference, safeExecResult, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
18
+ import { mkdirSyncReal, normalizedTmpdir, resolveAssetReference, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
19
+ import { getToolVersion, safeExecResult, } from '@vibe-agent-toolkit/utils/process';
20
+ import { killAllActiveClaudeChildren, probeAuthStatus, } from '@vibe-agent-toolkit/utils/skill-test';
19
21
  import { resolveSkillSource } from '../skill-source/resolve-skill-source.js';
22
+ import { BaselineArtifactSchema, computeBaselineDelta, formatBaselineDeltaLine, } from './baseline-delta.js';
23
+ import { activeContaminationSignals, armExpectationSkew, detectBaselineContamination, scrubControlArmEnv, skillContentNeedles, summarizeBaselineIntegrity, } from './baseline-integrity.js';
20
24
  import { assembleChildEnv, assertKnownEnvTokens, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
21
25
  import { runExecutorForEval } from './eval-executor.js';
22
26
  import { runGraderForEval } from './eval-grader.js';
23
- import { EvalInputError, parseEvalSuite, stageEvalWorkspaces } from './eval-inputs.js';
27
+ import { armDirSegment, EvalInputError, parseEvalSuite, stageEvalWorkspaces, } from './eval-inputs.js';
24
28
  import { lintEvalExpectations, lintToolExpectationExecutables } from './eval-lint.js';
25
29
  import { DEFAULT_EVALS_SUBPATH } from './eval-suite-isolation.js';
26
30
  import { writeEvalsTemplate } from './evals-template.js';
@@ -28,11 +32,12 @@ import { BootstrapNeededError, DuplicateStagedSkillError, InternalHarnessError,
28
32
  import { mergeFragmentsToFriction, mergeFragmentsToGrading, mergeFragmentsToToolEval } from './fragment-merge.js';
29
33
  import { FrictionReportSchema } from './friction-schema.js';
30
34
  import { DEFAULT_CONCURRENCY, DEFAULT_GRADER_MODEL } from './grader-model.js';
35
+ import { sanitizeGraderText } from './grader-text.js';
31
36
  import { reconcileGrading } from './grading-adapter.js';
32
37
  import { GradingReportSchema } from './grading-schema.js';
33
- import { assertSafeHarnessRoot, assertSafeWorkdir, prepareHarnessRoot, resolveHarnessRoot } from './harness-location.js';
38
+ import { assertSafeHarnessRoot, assertSafeWorkdir, HarnessLocationError, prepareHarnessRoot, resolveHarnessRoot, } from './harness-location.js';
34
39
  import { acquireHarnessLock, installSignalCleanup } from './lock.js';
35
- import { runPipeline } from './pipeline.js';
40
+ import { RateLimitSignal, runPipeline } from './pipeline.js';
36
41
  import { detectPluginLayout } from './plugin-layout.js';
37
42
  import { runPreflight } from './preflight.js';
38
43
  import { descriptorToSource, stageHarness } from './staging.js';
@@ -182,56 +187,98 @@ export function buildResolveCtx(harnessRoot, repoRoot) {
182
187
  };
183
188
  }
184
189
  /**
185
- * Map of flags to dummy values used when probing for flag support.
190
+ * Match a flag name as a whole token in `claude --help` output.
186
191
  *
187
- * Each value is chosen so value-validation (e.g., enum checks) accepts it
188
- * before `--help` short-circuits the session. The empty string `''` for
189
- * `--setting-sources` is intentional — an empty comma-separated list is valid.
192
+ * The boundary matters: a bare `includes('--plugin-dir')` also matches
193
+ * `--plugin-dirs` or `--plugin-dir-cache`, and vat's own flag names are prefixes
194
+ * of each other (`--max-turns` / `--max-turns-per-eval` would collide). Neither
195
+ * side of the token may be a letter or a dash.
190
196
  */
191
- const FLAG_DUMMY_VALUES = {
192
- '--plugin-dir': '.',
193
- '--setting-sources': '',
194
- '--output-format': 'stream-json',
195
- '--permission-mode': 'bypassPermissions',
196
- '--max-turns': '1',
197
- '--max-budget-usd': '1',
198
- };
197
+ export function helpTextDeclaresFlag(helpText, flag) {
198
+ // A scan rather than a built regex: the flag is data (it comes from a list a
199
+ // future edit will extend), and building a pattern from data is both a lint
200
+ // error here and the kind of thing that silently changes meaning when someone
201
+ // adds a flag containing a metacharacter.
202
+ const isTokenChar = (ch) => ch !== undefined && (ch === '-' || ch === '_' || /[a-z0-9]/i.test(ch));
203
+ for (let from = helpText.indexOf(flag); from !== -1; from = helpText.indexOf(flag, from + 1)) {
204
+ if (!isTokenChar(helpText[from - 1]) && !isTokenChar(helpText[from + flag.length]))
205
+ return true;
206
+ }
207
+ return false;
208
+ }
199
209
  /**
200
- * Return a sensible dummy value for a given CLI flag so that value-validation
201
- * doesn't reject the argument before `--help` can short-circuit the session.
210
+ * A flag name no `claude` will ever accept. It is probed alongside the real ones
211
+ * as a NEGATIVE CONTROL: if the probe reports this as supported, the probe is
212
+ * broken and its answer about every other flag is worthless.
202
213
  */
203
- export function flagDummyValueFor(flag) {
204
- return FLAG_DUMMY_VALUES[flag] ?? '1';
205
- }
214
+ export const FLAG_PROBE_SENTINEL = '--vat-probe-flag-that-cannot-exist';
206
215
  /**
207
- * Build a token-free flag-parse probe.
216
+ * Build a token-free flag-support probe from `claude --help`.
208
217
  *
209
- * For each flag, runs `claude <flag> <dummyValue> --help` via safeExecResult.
210
- * Exit 0 means claude's parser accepts the flag; `--help` short-circuits before
211
- * any session or tokens are consumed.
218
+ * ⚠️ This REPLACES an exit-code probe (`claude <flag> <dummy> --help`, exit 0 ⇒
219
+ * supported) that could not discriminate at all. Verified against claude 2.x:
220
+ * `claude --no-such-flag-xyz 1 --help` exits **0**, because `--help` short-circuits
221
+ * before argument validation. Every one of preflight's `flag <name>` checks
222
+ * therefore reported "supported" unconditionally — including for flags that do not
223
+ * exist — so the gate that is supposed to stop vat spawning with an unsupported
224
+ * flag was decorative. (The same command without `--help` exits 1, but running it
225
+ * would start a real session and bill tokens, which is why `--help` was there.)
212
226
  *
213
- * The dummy value is always included in the args, even when it is an empty
214
- * string — some flags (e.g., `--setting-sources`) require a value argument,
215
- * so omitting it causes exit 1 before `--help` can be reached.
227
+ * Help-text matching does discriminate, and it is still token-free: one `--help`
228
+ * invocation for the whole run instead of one spawn per flag.
216
229
  *
217
- * The probe is a closure — it is NOT spawned at module scope, only at call time
218
- * inside runPreflight.
230
+ * The probe carries its own negative control ({@link FLAG_PROBE_SENTINEL}). If a
231
+ * future `claude` prints something that matches the sentinel — or `--help` fails
232
+ * outright — the probe reports EVERY flag unsupported rather than every flag
233
+ * supported. A probe that cannot tell must fail closed and be seen; the defect it
234
+ * replaces failed open and was invisible.
219
235
  */
220
- function buildFlagParseProbe() {
221
- return (flag) => {
222
- const dummy = flagDummyValueFor(flag);
223
- const result = safeExecResult('claude', [flag, dummy, '--help'], {
224
- stdio: 'pipe',
225
- encoding: 'utf8',
226
- timeout: 15_000,
227
- });
228
- return result.success;
229
- };
236
+ export function buildFlagParseProbe(runHelp = defaultClaudeHelp) {
237
+ const helpText = runHelp();
238
+ // No help output, or a sentinel "match" ⇒ the probe is not trustworthy.
239
+ const usable = helpText !== null && !helpTextDeclaresFlag(helpText, FLAG_PROBE_SENTINEL);
240
+ return (flag) => usable && helpTextDeclaresFlag(helpText ?? '', flag);
241
+ }
242
+ /** Run `claude --help` once, returning its combined output (null if unreachable). */
243
+ function defaultClaudeHelp() {
244
+ const result = safeExecResult('claude', ['--help'], {
245
+ stdio: 'pipe',
246
+ encoding: 'utf8',
247
+ timeout: 15_000,
248
+ });
249
+ if (!result.success)
250
+ return null;
251
+ // `stdout`/`stderr` are typed `Buffer | string`; `encoding: 'utf8'` makes them
252
+ // strings, and `String()` is correct either way. Both streams are read because
253
+ // a CLI is free to print its usage to either.
254
+ return `${String(result.stdout)}\n${String(result.stderr)}`;
230
255
  }
256
+ /**
257
+ * ⚠️ KNOWN OPEN — `costEstimate` here is DEAD, in three independent ways, and none
258
+ * of them is fixable from inside this function:
259
+ *
260
+ * 1. `evalCount: 1` is a placeholder. The suite is not parsed until Step 5.5, which
261
+ * runs AFTER preflight, so the real count does not exist yet. Making it real means
262
+ * either moving preflight after the suite parse (it currently gates that parse) or
263
+ * adding a second reader of `evals.json` — and two readers of the answer key is
264
+ * precisely the drift this module refuses elsewhere.
265
+ * 2. `renderPreflightSummary` returns only FAILING checks, and it is called only
266
+ * inside the `if (!preflightResult.passed)` branch — so on a passing run the
267
+ * estimate never reaches a terminal at all.
268
+ * 3. Preflight runs after the `--dry-run` short-circuit, so a dry run never gets here.
269
+ *
270
+ * The consequence is that {@link buildDryRunSummary}'s spawn-count line is the ONLY
271
+ * pre-spend number an operator ever sees. Fix the count before making the check
272
+ * reachable: a reachable estimate that says "1 evals × 2 config" is worse than a
273
+ * silent one, because it is quotable.
274
+ */
231
275
  export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
232
276
  const costEstimate = {
233
277
  evalCount: 1,
234
- configurations: 1,
278
+ // `configurations` IS the A/B dimension: `--baseline` runs every eval under two
279
+ // configurations (skill declared, skill withheld). Correct even while
280
+ // `evalCount` is a placeholder, and it is the half that does not need the suite.
281
+ configurations: opts.baseline === true ? 2 : 1,
235
282
  runsPerQuery: 1,
236
283
  maxBudgetUsd: knobs.maxBudgetUsd,
237
284
  };
@@ -273,13 +320,31 @@ export function renderPreflightSummary(checks) {
273
320
  export function isAcknowledged(opts) {
274
321
  return opts.dryRun === true || opts.acknowledgedRunsSkillCode === true;
275
322
  }
323
+ /**
324
+ * Most friction lines vat will put on an operator's terminal in one run. The
325
+ * full set is always on disk; a grader that emits thousands of items must not
326
+ * be able to scroll the run's real output away.
327
+ */
328
+ const MAX_FRICTION_LINES = 50;
276
329
  /**
277
330
  * Format a friction report for human consumption — one line per entry as
278
331
  * `[<severity>] <category>: <message>`. Pure; returns the empty string for no
279
332
  * entries so the caller can skip emitting anything.
333
+ *
334
+ * `severity` and `category` are closed enums vat owns, so they are safe to
335
+ * interpolate. `message` is GRADER text and is sanitized here even though
336
+ * {@link parseEvalFragment} already sanitized it on the way in — this is a
337
+ * second, independent boundary, not a redundant one: {@link emitFrictionReport}
338
+ * re-reads `friction.json` from the harness results dir, which same-uid skill
339
+ * code can reach and rewrite after vat wrote it.
280
340
  */
281
341
  export function formatFrictionReport(items) {
282
- return items.map(i => `[${i.severity}] ${i.category}: ${i.message}`).join('\n');
342
+ const shown = items.slice(0, MAX_FRICTION_LINES);
343
+ const lines = shown.map(i => `[${i.severity}] ${i.category}: ${sanitizeGraderText(i.message)}`);
344
+ if (items.length > shown.length) {
345
+ lines.push(`... and ${items.length - shown.length} more (full list in friction.json)`);
346
+ }
347
+ return lines.join('\n');
283
348
  }
284
349
  /**
285
350
  * Read the run's friction.json (if present + valid) and echo a concise report to
@@ -309,6 +374,18 @@ function emitFrictionReport(frictionPath) {
309
374
  process.stderr.write(`\nPackaging friction (${parsed.data.items.length}):\n`);
310
375
  process.stderr.write(formatFrictionReport(parsed.data.items) + '\n');
311
376
  }
377
+ /**
378
+ * Write the `--baseline` delta line and ⚠️ banner to stderr, if this run produced
379
+ * one. A no-op on a non-baseline run.
380
+ *
381
+ * A function rather than an `if` at the call site purely to keep the orchestrator
382
+ * inside its cognitive-complexity budget; the ORDER it is called in is the part
383
+ * that matters, and that is documented where it is called.
384
+ */
385
+ function emitBaselineReport(report) {
386
+ if (report !== undefined)
387
+ process.stderr.write(report);
388
+ }
312
389
  /**
313
390
  * Name any optional companions (`--with-optional`/config `optional:`) that were
314
391
  * skipped because their source could not be resolved — never silently, so the
@@ -419,24 +496,66 @@ export function resolveEvalSuitePath(input) {
419
496
  return undefined;
420
497
  return safePath.join(input.holdDir, basename(input.evalsSubpath));
421
498
  }
499
+ /**
500
+ * The per-run executor workspace root: `<tmp>/vat-skill-test-ws-<dirToken>/`.
501
+ *
502
+ * Deliberately OUTSIDE `harnessRoot`, alongside {@link resolveGraderOutDir} and
503
+ * {@link resolveEvalSuiteHoldDir}. The executor's cwd lives under here, and the
504
+ * harness root is where vat stages runnable copies of the skill (`staged/` and
505
+ * the assembled plugin dir). While the two were parent and child, the skill-absent
506
+ * arm of a `--baseline` run sat one `ls ..` away from the treatment it was
507
+ * supposed to be denied — the control could reach vat's own copy without ever
508
+ * leaving its working directory.
509
+ *
510
+ * Named by an unpredictable token, like the other vat-only dirs, and removed by
511
+ * the same cleanup. Pure (derives a path only).
512
+ */
513
+ export function resolveWorkspacesRoot(dirToken) {
514
+ return safePath.join(normalizedTmpdir(), `vat-skill-test-ws-${dirToken}`);
515
+ }
422
516
  /** Parse the staged eval suite and materialize each eval's input `files` into
423
- * `<harnessRoot>/workspaces/<id>/`. Returns the workspaces root, the parsed
424
- * {@link EvalSuite} (so the eval loop has the entries without re-reading), and the
425
- * declared eval count (derived from the suite). The dir is wiped first so a reused
426
- * harness root cannot leak a prior run's inputs. Throws {@link EvalInputError}
427
- * (mapped by the caller to exit 2) on a bad suite or a missing input file. */
428
- export function stageWorkspacesForRun(evalsPath, harnessRoot) {
429
- const workspacesRoot = safePath.joinUnderRoot(harnessRoot, 'workspaces');
517
+ * `<workspacesRoot>/<arm>/<id>/`, once per arm this run will execute. Returns the
518
+ * workspaces root, the parsed {@link EvalSuite} (so the eval loop has the entries
519
+ * without re-reading), and the declared eval count (derived from the suite). The
520
+ * dir is wiped first so a reused root cannot leak a prior run's inputs. Throws
521
+ * {@link EvalInputError} (mapped by the caller to exit 2) on a bad suite or a
522
+ * missing input file. */
523
+ /**
524
+ * Mint this run's opaque per-arm workspace directory segments.
525
+ *
526
+ * Independent random tokens, NOT the arm names and not derived from one another:
527
+ * the segment ends up in the executor's cwd, which the prompt states verbatim, so
528
+ * a name like `without` unblinds the control arm outright — and a name like `with`
529
+ * hands it a guessable path to the treatment arm's live output one directory over.
530
+ * 8 bytes is ample for "not guessable within a run that lasts minutes"; these are
531
+ * blinding/anti-enumeration tokens, not secrets protecting data at rest.
532
+ */
533
+ export function mintArmWorkspaceDirs(baseline) {
534
+ const token = () => randomBytes(8).toString('hex');
535
+ return baseline ? { with: token(), without: token() } : { with: token() };
536
+ }
537
+ export function stageWorkspacesForRun(evalsPath, workspacesRoot, armDirs) {
430
538
  rmSync(workspacesRoot, { recursive: true, force: true });
431
539
  // 0700, matching the harness root. These dirs hold each eval's declared input
432
540
  // files, which with an out-of-tree suite may be data that was never in the repo
433
541
  // at all. Inheriting the umask (0755) left them readable by any local user the
434
542
  // moment `--out` relocated the harness root out from under its 0700 parent.
435
543
  mkdirSyncReal(workspacesRoot, { recursive: true, mode: 0o700 });
544
+ // The same shared-tmp hardening the harness root gets. `mkdirSync(recursive)`
545
+ // on an existing path neither throws nor chmods, so without this an attacker
546
+ // winning the race between the rmSync and the mkdir owns the executor's working
547
+ // directory and nothing ever re-checks. Cheap: the leaf sits directly under the
548
+ // trusted tmp boundary, so the ancestry walk degrades to one lstat + stat.
549
+ assertSafeHarnessRoot(workspacesRoot, process.getuid?.() ?? -1);
436
550
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- evalsPath is our staged-subject path
437
551
  const suite = parseEvalSuite(readFileSync(evalsPath, 'utf-8'));
438
552
  return {
439
- workspacesRoot: stageEvalWorkspaces({ suite, evalsDir: dirname(evalsPath), workspacesRoot }),
553
+ workspacesRoot: stageEvalWorkspaces({
554
+ suite,
555
+ evalsDir: dirname(evalsPath),
556
+ workspacesRoot,
557
+ armDirs,
558
+ }),
440
559
  declaredEvalCount: suite.evals.length,
441
560
  suite,
442
561
  };
@@ -453,9 +572,9 @@ function buildModelFlag(model) {
453
572
  * Keeping the try/catch in a private helper avoids inflating the orchestrator's
454
573
  * cognitive complexity.
455
574
  */
456
- function attemptStageWorkspaces(evalsPath, harnessRoot) {
575
+ function attemptStageWorkspaces(evalsPath, harnessRoot, workspacesRoot, armDirs) {
457
576
  try {
458
- return stageWorkspacesForRun(evalsPath, harnessRoot);
577
+ return stageWorkspacesForRun(evalsPath, workspacesRoot, armDirs);
459
578
  }
460
579
  catch (e) {
461
580
  if (e instanceof EvalInputError) {
@@ -576,6 +695,38 @@ export function buildStaleDistWarningLines(roleLabel) {
576
695
  * Always includes the assembled spawn command, the staged-manifest entry count +
577
696
  * fingerprint, and the provenance.json path so a stale tree is visible at a glance.
578
697
  */
698
+ /** `s` unless the count is exactly one — the plural the two spawn-plan lines share. */
699
+ function plural(count) {
700
+ return count === 1 ? '' : 's';
701
+ }
702
+ /**
703
+ * The two lines that say WHAT A REAL RUN WOULD COST, in PAIRS — not evals.
704
+ *
705
+ * `--baseline` runs every eval on both arms and each arm is a full executor→grader
706
+ * pair, so the spawn count is `evals × arms` and the `claude` session count is twice
707
+ * that. Reporting the eval count as the pair count understated a baseline run by
708
+ * exactly 2x: a 3-eval suite previewed as "3 evals as executor→grader spawn pairs"
709
+ * and then launched 6 pairs / 12 sessions.
710
+ *
711
+ * The arm count is spelled out rather than folded silently into the number, because
712
+ * "6 pairs" for a 3-eval suite reads as a bug until you are told why. And the budget
713
+ * flag is labelled PER SPAWN with the ceiling multiplied out, because the name
714
+ * `--max-budget-usd` reads as a run total — under `--baseline` that misreading
715
+ * under-provisions by a factor of four times the eval count.
716
+ */
717
+ function buildSpawnPlanLines(input) {
718
+ const pairs = input.evalCount * (input.baseline ? 2 : 1);
719
+ const sessions = pairs * 2;
720
+ const armNote = input.baseline
721
+ ? ` (${input.evalCount} eval${plural(input.evalCount)} × 2 arms — --baseline runs each eval with AND without the skill)`
722
+ : '';
723
+ return [
724
+ `[dry-run] Would run ${pairs} executor→grader spawn pair${plural(pairs)}${armNote} ` +
725
+ `at concurrency ${input.concurrency} — ${sessions} claude session${plural(sessions)} in total.`,
726
+ `[dry-run] --max-budget-usd is PER SPAWN ($${input.maxBudgetUsd}), not per run: worst case ≈ ` +
727
+ `$${(sessions * input.maxBudgetUsd).toFixed(2)} across those ${sessions} sessions.`,
728
+ ];
729
+ }
579
730
  export function buildDryRunSummary(input) {
580
731
  const lines = [];
581
732
  if (input.wouldBuild) {
@@ -605,17 +756,37 @@ export function buildDryRunSummary(input) {
605
756
  // it is labelled rather than left to be taken at face value.
606
757
  const stagedFromUnbuiltTree = input.wouldBuild && input.dryRunStagedExistingDist !== undefined;
607
758
  const provisional = stagedFromUnbuiltTree ? ' (PROVISIONAL — not rebuilt)' : '';
608
- lines.push(`[dry-run] Would run ${input.evalCount} eval${input.evalCount === 1 ? '' : 's'} as executor→grader spawn ` +
609
- `pair${input.evalCount === 1 ? '' : 's'} at concurrency ${input.concurrency}.`, `[dry-run] Executor ${input.modelFlag}; grader model ${input.graderModel} (prompt via stdin).`, `[dry-run] Staged manifest: ${count} entr${count === 1 ? 'y' : 'ies'} | ` +
610
- `fingerprint: ${input.provenanceFingerprint}${provisional}`, `[dry-run] Provenance: ${input.provenancePath}`);
759
+ lines.push(...buildSpawnPlanLines(input), `[dry-run] Executor ${input.modelFlag}; grader model ${input.graderModel} (prompt via stdin).`, `[dry-run] Staged manifest: ${count} entr${count === 1 ? 'y' : 'ies'} | ` +
760
+ `fingerprint: ${input.provenanceFingerprint}${provisional}`, `[dry-run] Provenance would be written to: ${input.provenancePath}`);
611
761
  return lines.join('\n');
612
762
  }
613
763
  /**
614
- * Remove the harness directory after a run so staged untrusted skill bytes and
615
- * prompts do not accumulate in OS tmp. No-op when the user asked to keep it, when
616
- * the dir is a user-supplied location (`--out`/`--workdir`), or when it is already
617
- * gone. Idempotent and never throws — it runs from a `finally`, so it must not
618
- * mask the run's real outcome.
764
+ * The one child of the harness root that cleanup NEVER removes. Cleanup exists to
765
+ * evict staged untrusted skill bytes and prompts from OS tmp; `results/` is the
766
+ * opposite — it is the run's product, written solely by vat.
767
+ */
768
+ export const RETAINED_RESULTS_DIRNAME = 'results';
769
+ /**
770
+ * Remove the harness directory's staged contents after a run so untrusted skill
771
+ * bytes and prompts do not accumulate in OS tmp. No-op when the user asked to keep
772
+ * it, when the dir is a user-supplied location (`--out`/`--workdir`), or when it is
773
+ * already gone. Idempotent and never throws — it runs from a `finally`, so it must
774
+ * not mask the run's real outcome.
775
+ *
776
+ * `results/` SURVIVES. It holds `grading.json`, `friction.json`, `tool-eval.json`
777
+ * and — for a `--baseline` run — `baseline.json`, i.e. every artifact the command's
778
+ * own help text tells the operator to go read. Removing the whole root on the
779
+ * DEFAULT invocation (no `--out`, no `--workdir`, no `--keep`) deleted those files
780
+ * inside this function's own `finally`, before `run.ts` had printed a single line:
781
+ * the operator was handed a `Harness:` path to a directory that no longer existed.
782
+ * That is the invocation `vat-skill-testing.md`'s copy-paste example uses.
783
+ *
784
+ * The retention is deliberately UNCONDITIONAL rather than `--baseline`-only: every
785
+ * run's verdict detail lives in the same directory, and a rule that depends on a
786
+ * flag is a rule that will drift away from the flag. The cost is bounded — the
787
+ * harness root is a deterministic function of the subject set, so a re-run reuses
788
+ * (and {@link wipeStaleArtifacts} clears) the same `results/`, rather than
789
+ * accumulating one per run.
619
790
  *
620
791
  * SAFETY: re-asserts the root is not a symlink immediately before removal (via
621
792
  * `lstat`, which does NOT follow the link). A root swapped to a symlink between
@@ -635,7 +806,20 @@ export function cleanupHarness(harnessRoot, opts) {
635
806
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
636
807
  if (lstatSync(harnessRoot).isSymbolicLink())
637
808
  return;
638
- rmSync(harnessRoot, { recursive: true, force: true });
809
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
810
+ const entries = readdirSync(harnessRoot);
811
+ // Nothing to retain — a run that ended before Step 7 (preflight refusal, lock
812
+ // failure, a throw during staging) has no results/, so leaving an empty 0700
813
+ // dir behind in tmp would be pure litter. Remove the root outright.
814
+ if (!entries.includes(RETAINED_RESULTS_DIRNAME)) {
815
+ rmSync(harnessRoot, { recursive: true, force: true });
816
+ return;
817
+ }
818
+ for (const entry of entries) {
819
+ if (entry === RETAINED_RESULTS_DIRNAME)
820
+ continue;
821
+ rmSync(safePath.joinUnderRoot(harnessRoot, entry), { recursive: true, force: true });
822
+ }
639
823
  }
640
824
  catch {
641
825
  // Swallow: a failed cleanup is not a run failure.
@@ -699,14 +883,43 @@ export function resolveGraderOutDir(dirToken) {
699
883
  return safePath.join(normalizedTmpdir(), `vat-skill-grade-${dirToken}`);
700
884
  }
701
885
  /**
702
- * The executor working directory for one eval: its staged input workspace
703
- * `<workspacesRoot>/<id>` when the eval declares input `files`, else undefined
704
- * (the executor then defaults to the staged subject dir). Pure + unit-testable.
886
+ * The executor working directory for one eval ON ONE ARM: its staged input
887
+ * workspace `<workspacesRoot>/<arm>/<id>`, ALWAYS — empty when the eval declares
888
+ * no input `files` (see {@link stageEvalWorkspaces}).
889
+ *
890
+ * This used to return undefined for a file-less eval, which made the executor
891
+ * fall back to running inside the staged subject dir. That fallback was the
892
+ * quiet half of the `--baseline` control defect: the skill-absent arm's cwd
893
+ * was the skill.
894
+ *
895
+ * The per-arm segment closes the OTHER half, found a round later: without it both
896
+ * arms of one eval shared a single writable directory AND ran concurrently, so
897
+ * the control arm could read the treatment arm's output files and answer from
898
+ * them. Keyed on the eval id alone this is a pure function of the eval; the arm
899
+ * is what makes the two runs independent.
900
+ *
901
+ * The segment is an OPAQUE per-run token, never the arm's name — this path is
902
+ * quoted verbatim to the executor as its working directory, so `…/without/e1`
903
+ * told the control arm it was the control, and `../with/e1` told it where to look.
904
+ * See {@link mintArmWorkspaceDirs}. Pure + unit-testable.
905
+ */
906
+ export function resolvePerEvalWorkspaceDir(entry, workspacesRoot, arm, armDirs) {
907
+ return safePath.joinUnderRoot(armWorkspaceRoot(workspacesRoot, armDirs, arm), String(entry.id));
908
+ }
909
+ /**
910
+ * `<workspacesRoot>/<that arm's opaque segment>` — ONE arm's staged workspace root,
911
+ * derived in exactly one place.
912
+ *
913
+ * The consolidation is the point, not the line count. Every consumer that wants an
914
+ * arm root wants this join, and a consumer that takes the result as a bare `string`
915
+ * has no way to tell an arm root from the workspaces ROOT one level above it:
916
+ * handing {@link resolveSkillContentNeedles} `workspacesRoot` instead of
917
+ * `<workspacesRoot>/<withSegment>` type-checked, ran, and left all 2270 unit tests
918
+ * green while every fixture exclusion silently missed. Callers now pass the two
919
+ * inputs and let this derive the path.
705
920
  */
706
- export function resolvePerEvalWorkspaceDir(entry, workspacesRoot) {
707
- if (entry.files === undefined || entry.files.length === 0)
708
- return undefined;
709
- return safePath.joinUnderRoot(workspacesRoot, String(entry.id));
921
+ function armWorkspaceRoot(workspacesRoot, armDirs, arm) {
922
+ return safePath.joinUnderRoot(workspacesRoot, armDirSegment(armDirs, arm));
710
923
  }
711
924
  /**
712
925
  * Best-effort removal of a vat-only tmp dir that lives OUTSIDE the harness root —
@@ -714,6 +927,36 @@ export function resolvePerEvalWorkspaceDir(entry, workspacesRoot) {
714
927
  * cleanup, where masking the run's real outcome would be worse than a leftover 0700
715
928
  * tmp dir) and refuses to follow a symlinked root.
716
929
  */
930
+ /**
931
+ * Run one cleanup step, swallowing whatever it throws.
932
+ *
933
+ * The discipline every other step in the harness `finally` already follows, stated
934
+ * once: a cleanup failure is not a run failure, and letting one out REPLACES an
935
+ * already-good result — verdict computed, artifacts written, summary composed — with
936
+ * an error and exit 1.
937
+ *
938
+ * ⚠️ A CALL-SITE GUARD IS THE WEAK FORM, and this helper is a last resort rather
939
+ * than the pattern to copy. It is invisible to the compiler and to the test suite:
940
+ * reverting either of its two original call sites to a bare call left the unit suite
941
+ * AND the integration suite green, so nothing but review stood between the fix and
942
+ * its own silent removal. `lock.release()` was therefore made non-throwing at its own
943
+ * definition instead (see {@link acquireHarnessLock}), which is a contract the type
944
+ * carries and no edit here can undo. That leaves ONE caller —
945
+ * `killAllActiveClaudeChildren`, which lives in `@vibe-agent-toolkit/utils` and is
946
+ * only contractually non-throwing as long as that package says so.
947
+ *
948
+ * The failure is reported on stderr rather than silently dropped: a lockfile that
949
+ * could not be removed will break the NEXT run with a "busy" error, and an operator
950
+ * who saw nothing here has no way to connect the two.
951
+ */
952
+ export function swallowCleanupFailure(step) {
953
+ try {
954
+ step();
955
+ }
956
+ catch (err) {
957
+ process.stderr.write(`warning: harness cleanup step failed (the run's result stands): ${err instanceof Error ? err.message : String(err)}\n`);
958
+ }
959
+ }
717
960
  export function removeVatOnlyDir(dir) {
718
961
  if (dir === undefined)
719
962
  return;
@@ -737,34 +980,108 @@ export function removeVatOnlyDir(dir) {
737
980
  * absent). Grader fragments are written under a PER-ARM subdir of the vat-only
738
981
  * grader dir so a WITH and WITHOUT run of the same eval id cannot collide.
739
982
  *
740
- * Throws propagate: an executor/grader {@link InternalHarnessError} (timeout,
741
- * stall, spawn error, grader failure/missing-fragment) fails the whole run
742
- * (exit 1); a RateLimitSignal is retried by the pipeline. An executor CLEAN
743
- * failure is NOT thrown — its transcript flows into the grader, whose failing
983
+ * Throws, and the caller ({@link runEvalWorker}) decides what a throw costs — see
984
+ * its docblock for why that depends on the arm. An executor CLEAN failure is NOT
985
+ * thrown on either arm: its transcript flows into the grader, whose failing
744
986
  * fragment surfaces as an eval failure (exit 4 via the verdict), never exit 1.
745
987
  */
746
- async function runEvalWorker(item, ctx) {
988
+ /**
989
+ * The executor env for ONE eval.
990
+ *
991
+ * `workspaceDir` is threaded to the token resolver ONLY when the eval actually
992
+ * declares input `files`, even though every eval now HAS a workspace. The two are
993
+ * different questions: the workspace is where the executor runs, while
994
+ * `${fixturesDir}` promises a staged `fixtures/` directory that EXISTS — and
995
+ * `stageEvalWorkspaces` creates that only for evals declaring files. Passing it
996
+ * unconditionally silently downgraded `UnresolvableEnvTokenError` (a loud exit 2
997
+ * naming the exact fix) into a dead path the skill discovers at runtime and is
998
+ * then blamed for.
999
+ */
1000
+ function resolveExecutorEnvForEval(item, ctx, workspaceDir) {
1001
+ if (ctx.perEvalEnv === undefined)
1002
+ return ctx.executorEnv;
1003
+ const declaresFiles = item.entry.files !== undefined && item.entry.files.length > 0;
1004
+ const { input, logged } = ctx.perEvalEnv;
1005
+ const env = resolveDeclaredChildEnv({
1006
+ ...input,
1007
+ ...(declaresFiles ? { workspaceDir } : {}),
1008
+ quiet: logged.done,
1009
+ }).env;
1010
+ logged.done = true;
1011
+ return env;
1012
+ }
1013
+ /**
1014
+ * Scrub the control arm's env and SAY what it lost.
1015
+ *
1016
+ * The arms are meant to differ in the skill and nothing else, so any var withheld
1017
+ * from one side is a confound. A control arm silently spawned without `PATH` or
1018
+ * without its declared fixtures still runs — it just does worse, which reports as
1019
+ * skill lift. That failure is invisible unless the withholding is announced, and
1020
+ * `dropped` was previously computed and thrown away.
1021
+ */
1022
+ function scrubAndReportControlArmEnv(env, harnessRoot, evalId) {
1023
+ // `[]` because vat does not declare model env vars today — `resolveAuth` is
1024
+ // called with no `modelVars`, so none is ever forwarded and the exemption set
1025
+ // has nothing extra to hold. The parameter is REQUIRED rather than defaulted so
1026
+ // that stops being a silent assumption: the day vat forwards a model var, this
1027
+ // line fails to compile and someone decides, instead of the scrub quietly
1028
+ // dropping it and running the control arm on a different model.
1029
+ const { env: scrubbed, droppedForbiddenKey, droppedNamingRoot, retainedLeaks } = scrubControlArmEnv(env, harnessRoot, []);
1030
+ // Two rules, two reasons, two lines. One merged line said "naming the harness
1031
+ // root" about both, which is false for every rule-1 drop — those go by NAME and
1032
+ // their value may name nothing in the harness at all.
1033
+ const withheld = [
1034
+ [droppedForbiddenKey, 'by name, whatever their value'],
1035
+ [droppedNamingRoot, 'because their value names the harness root'],
1036
+ ];
1037
+ for (const [names, why] of withheld) {
1038
+ if (names.length === 0)
1039
+ continue;
1040
+ process.stderr.write(`control arm (${evalId}): withheld ${names.length} env var(s) ${why}: ${names.join(', ')}\n`);
1041
+ }
1042
+ if (retainedLeaks.length > 0) {
1043
+ process.stderr.write(`⚠️ control arm (${evalId}): ${retainedLeaks.join(', ')} name(s) the harness root but are required to run, so they were NOT withheld. ` +
1044
+ `The control arm can read the harness through them — move --out/--workdir off this path before trusting the delta.\n`);
1045
+ }
1046
+ return scrubbed;
1047
+ }
1048
+ async function gradeOneArm(item, ctx) {
747
1049
  const evalId = String(item.entry.id);
748
- const workspaceDir = resolvePerEvalWorkspaceDir(item.entry, ctx.workspacesRoot);
1050
+ const workspaceDir = resolvePerEvalWorkspaceDir(item.entry, ctx.workspacesRoot, item.arm, ctx.armDirs);
749
1051
  const onProgress = (chunk) => { process.stderr.write(chunk); };
750
- // Resolve the declared `env` against THIS eval's workspace. An eval that
751
- // declares no input files has no workspace, so a value using ${fixturesDir}
752
- // throws UnresolvableEnvTokenError (exit 2) instead of injecting a dead path.
753
- let executorEnv = ctx.executorEnv;
754
- if (ctx.perEvalEnv !== undefined) {
755
- const { input, logged } = ctx.perEvalEnv;
756
- executorEnv = resolveDeclaredChildEnv({
757
- ...input,
758
- ...(workspaceDir === undefined ? {} : { workspaceDir }),
759
- quiet: logged.done,
760
- }).env;
761
- logged.done = true;
762
- }
1052
+ // Resolve the declared `env` against THIS eval's workspace.
1053
+ //
1054
+ // `workspaceDir` is threaded to the token resolver ONLY when the eval actually
1055
+ // declares input `files`, even though every eval now HAS a workspace. The two
1056
+ // are different questions: the workspace is where the executor runs, while
1057
+ // `${fixturesDir}` promises a staged `fixtures/` directory that exists — and
1058
+ // `stageEvalWorkspaces` creates that only for evals declaring files. Passing it
1059
+ // unconditionally silently downgraded `UnresolvableEnvTokenError` (a loud exit 2
1060
+ // naming the exact fix) into a dead path the skill discovers at runtime and gets
1061
+ // blamed for.
1062
+ let executorEnv = resolveExecutorEnvForEval(item, ctx, workspaceDir);
1063
+ // The skill-absent arm is not told where the subject is staged. Handing it that
1064
+ // path would defeat the control outright: the staged dir holds the SKILL.md AND
1065
+ // any executable the skill ships, so a control arm given the path can recover
1066
+ // the entire treatment with one `cat`. Withholding the plugin dir while naming
1067
+ // the staged dir in the prompt was the defect this arm's whole value rests on.
1068
+ const isWithArm = item.arm === 'with';
1069
+ // ...and the same reasoning applied to the ENVIRONMENT, which is the channel the
1070
+ // first round of this fix missed entirely: the run assembles ONE env and both
1071
+ // arms were handed it verbatim, `CLAUDE_PLUGIN_ROOT` included. See
1072
+ // scrubControlArmEnv — prompt, argv, cwd and env are four channels, not three.
1073
+ //
1074
+ // Report what the control arm lost. The arms are supposed to differ in the skill
1075
+ // and nothing else, so any var withheld from one side is a confound the operator
1076
+ // has to be able to see — silently spawning a degraded control is how a harness
1077
+ // manufactures the lift it is meant to measure.
1078
+ if (!isWithArm)
1079
+ executorEnv = scrubAndReportControlArmEnv(executorEnv, ctx.harnessRoot, evalId);
763
1080
  const outcome = await runExecutorForEval({
764
1081
  evalId,
765
1082
  task: item.entry.prompt,
766
- subjectStagedDir: ctx.subjectStagedDir,
767
- ...(workspaceDir === undefined ? {} : { workspaceDir }),
1083
+ ...(isWithArm ? { subjectStagedDir: ctx.subjectStagedDir } : {}),
1084
+ workspaceDir,
768
1085
  pluginDirs: item.arm === 'without' ? [] : ctx.pluginDirs,
769
1086
  env: executorEnv,
770
1087
  ...(ctx.model === undefined ? {} : { model: ctx.model }),
@@ -778,7 +1095,6 @@ async function runEvalWorker(item, ctx) {
778
1095
  recordSessionCost(ctx.costAccumulator, outcome.parsed.result?.totalCostUsd);
779
1096
  // Tool expectations are about the SKILL's tools, so they ride the WITH arm ONLY —
780
1097
  // the WITHOUT (skill-absent) arm has no skill, hence nothing to judge tools against.
781
- const isWithArm = item.arm === 'with';
782
1098
  const fragment = await runGraderForEval({
783
1099
  evalId,
784
1100
  transcript: outcome.transcript,
@@ -803,7 +1119,384 @@ async function runEvalWorker(item, ctx) {
803
1119
  onProgress,
804
1120
  costSink: (usd) => recordSessionCost(ctx.costAccumulator, usd),
805
1121
  });
806
- return { ...fragment, arm: item.arm };
1122
+ const graded = withoutGraderContamination(fragment);
1123
+ if (isWithArm)
1124
+ return { ...graded, arm: item.arm };
1125
+ return { ...graded, arm: item.arm, ...baselineContaminationFor(outcome.transcript, ctx, evalId) };
1126
+ }
1127
+ /**
1128
+ * Run one work item and decide what its failure costs the RUN.
1129
+ *
1130
+ * TREATMENT arm: rethrow. `grading.json` is the treatment arm, the verdict is
1131
+ * computed from it, and a run that cannot produce one has produced nothing — there
1132
+ * is no partial result worth writing, so the throw propagates through `runPipeline`
1133
+ * to `mapErrorToExitCode` exactly as before (exit 1).
1134
+ *
1135
+ * CONTROL arm: record and continue. Every failure mode of this arm — executor spawn
1136
+ * error, stall, wall-timeout, grader non-zero exit, grader wrote no fragment,
1137
+ * fragment is not valid JSON, nonce mismatch, and (newest) a grader that returned a
1138
+ * different number of expectations than the eval declares — used to propagate out of
1139
+ * `runPipeline` and annihilate the treatment work already completed and billed. A
1140
+ * two-eval probe was measured doing precisely that: both treatment executors ran,
1141
+ * both treatment graders ran, the control arm's executor timed out, the run threw,
1142
+ * and `results/` was left holding `provenance.json` and nothing else. The operator
1143
+ * paid for the whole run and got no grading.json, no baseline.json, and an error
1144
+ * that did not even say which arm had died.
1145
+ *
1146
+ * Recording it instead costs the DELTA, which is the only thing the control arm
1147
+ * feeds, and the delta already has a truthful way to be absent (`null`). The
1148
+ * treatment artifacts are written, the failure is named on stderr as it happens and
1149
+ * again in `baselineIntegrity.controlArmFailures`, and `armExpectationSkew` sees an
1150
+ * eval graded on one arm only and withholds the number.
1151
+ *
1152
+ * A {@link RateLimitSignal} is rethrown on BOTH arms — it is not a failure, it is the
1153
+ * pipeline's retry protocol, and swallowing it on the control arm would convert every
1154
+ * transient rate limit into a permanently dead control arm. The worker CANNOT tell a
1155
+ * first 429 from the last one; only the pipeline knows the budget is spent, which is
1156
+ * what {@link rateLimitExhaustedOutcome} is wired to handle.
1157
+ */
1158
+ async function runEvalWorker(item, ctx) {
1159
+ try {
1160
+ return { fragment: await gradeOneArm(item, ctx) };
1161
+ }
1162
+ catch (err) {
1163
+ if (err instanceof RateLimitSignal || item.arm === 'with')
1164
+ throw nameTheArm(err, item.arm);
1165
+ return recordControlFailure(item, err);
1166
+ }
1167
+ }
1168
+ /**
1169
+ * The control arm ran out of rate-limit retries — record it and let the run live.
1170
+ *
1171
+ * The retryable case is handled correctly by {@link runEvalWorker} and must stay that
1172
+ * way; this is the EXHAUSTED case, which used to be indistinguishable from it. A
1173
+ * control executor rate-limited on every attempt threw after five retries, taking
1174
+ * down a run in which both treatment executors and both treatment graders had already
1175
+ * completed and been billed, leaving `results/` holding `provenance.json` and nothing
1176
+ * else — the exact failure {@link runEvalWorker} exists to prevent, reached by the one
1177
+ * error class it had to let through.
1178
+ *
1179
+ * The treatment arm still hard-fails: there is no treatment result to salvage, which
1180
+ * is the same asymmetry {@link EvalWorkOutcome} documents.
1181
+ *
1182
+ * Wired as the pipeline's `onRetriesExhausted`, and `--baseline` — which doubles the
1183
+ * spawn count — is the run most likely to need it.
1184
+ */
1185
+ export function rateLimitExhaustedOutcome(item, error) {
1186
+ if (item.arm === 'with')
1187
+ throw nameTheArm(error, item.arm);
1188
+ return recordControlFailure(item, error);
1189
+ }
1190
+ /**
1191
+ * Turn one control-arm error into a recorded failure the run carries past. Shared by
1192
+ * the worker's catch and the exhausted-retries handler so both report identically —
1193
+ * an operator must not be able to tell which layer noticed.
1194
+ */
1195
+ function recordControlFailure(item, err) {
1196
+ const named = nameTheArm(err, item.arm);
1197
+ // `detail` is schema-required non-empty: an error that stringifies to nothing
1198
+ // would otherwise fail the artifact write and take down the run this whole
1199
+ // branch exists to keep alive.
1200
+ const detail = (named instanceof Error ? named.message : String(named)) ||
1201
+ `[${ARM_LABEL[item.arm]}] failed for eval "${String(item.entry.id)}" with a non-descriptive error`;
1202
+ process.stderr.write(`\n⚠️ ${detail}\n` +
1203
+ ' The control arm for this eval is unusable, so the --baseline delta will be WITHHELD. ' +
1204
+ 'The treatment arm is unaffected and the run continues — its grading is what you paid for.\n');
1205
+ return { controlFailure: { evalId: String(item.entry.id), detail } };
1206
+ }
1207
+ /**
1208
+ * Fragment fields VAT derives itself and therefore never accepts FROM a grader.
1209
+ *
1210
+ * Both are computed from the executor transcript's structure, which the grader is
1211
+ * shown as opaque text and never asked about — so a value in either from a grader
1212
+ * is not merely redundant, it is a claim the grader is in no position to make.
1213
+ */
1214
+ const VAT_ATTACHED_FRAGMENT_KEYS = ['contamination', 'degraded'];
1215
+ /**
1216
+ * Strip the fields VAT attaches itself, before VAT attaches them.
1217
+ *
1218
+ * The grader's only input is the executor transcript, which untrusted skill code
1219
+ * controls — so a prompt injection there could otherwise fabricate a
1220
+ * `BASELINE CONTAMINATED` verdict carrying attacker-chosen strings into an
1221
+ * operator-facing artifact, or (via `degraded`) claim a scan degraded that did not.
1222
+ * Spreading VAT's value last is NOT sufficient: it is absent on a clean run, so it
1223
+ * overwrites nothing — which is exactly why the omission direction matters more
1224
+ * than the invention one for `degraded`. A grader that omits it cannot re-hide a
1225
+ * degradation VAT detected, because VAT's value is spread after this strip; a
1226
+ * grader that INVENTS it would otherwise stamp a blind-scan warning on a run that
1227
+ * scanned properly, and an operator who learns to ignore that warning stops reading
1228
+ * the real ones.
1229
+ */
1230
+ export function withoutGraderContamination(fragment) {
1231
+ if (VAT_ATTACHED_FRAGMENT_KEYS.every((key) => fragment[key] === undefined))
1232
+ return fragment;
1233
+ const copy = { ...fragment };
1234
+ for (const key of VAT_ATTACHED_FRAGMENT_KEYS)
1235
+ delete copy[key];
1236
+ return copy;
1237
+ }
1238
+ /**
1239
+ * How each arm is named in every message that can only be about one of them.
1240
+ *
1241
+ * Spelled out rather than `'with'`/`'without'`: the internal names are a preposition
1242
+ * apart and the operator-facing question is "which half of my experiment died", so
1243
+ * `Executor timed out for eval "e1"` — the actual text a destroyed run used to print
1244
+ * — named neither the arm nor the fact that the OTHER arm had already succeeded and
1245
+ * been thrown away.
1246
+ */
1247
+ const ARM_LABEL = {
1248
+ with: 'treatment arm (skill available)',
1249
+ without: 'control arm (skill withheld)',
1250
+ };
1251
+ /**
1252
+ * Prefix a worker error's message with the arm it came from, IN PLACE.
1253
+ *
1254
+ * Mutating rather than re-wrapping is deliberate. `mapErrorToExitCode` dispatches on
1255
+ * the error's CLASS, and so does the run's own regression test for a forged grader
1256
+ * nonce (`rejects.toThrow(GradingNonceError)`); wrapping every failure in a fresh
1257
+ * `InternalHarnessError` to get a better message would flatten
1258
+ * `EvalFragmentError`/`GradingNonceError`/`RateLimitSignal` into one class and lose
1259
+ * both. The stack is preserved for the same reason.
1260
+ *
1261
+ * Idempotent — a worker error passes through here exactly once, but a retried item
1262
+ * must not accumulate prefixes.
1263
+ */
1264
+ function nameTheArm(err, arm) {
1265
+ const prefix = `[${ARM_LABEL[arm]}] `;
1266
+ if (err instanceof Error && !err.message.startsWith(prefix))
1267
+ err.message = `${prefix}${err.message}`;
1268
+ return err;
1269
+ }
1270
+ /**
1271
+ * Lift the run's content needles off the STAGED SKILL.md.
1272
+ *
1273
+ * Staged, not source: the staged copy is byte-identical to what the treatment arm
1274
+ * is given, so a needle taken from it is exactly the text an ambient copy of the
1275
+ * same build would carry.
1276
+ *
1277
+ * The exclusion set is EVERY channel through which vat itself hands the arm text:
1278
+ * the suite's prompts, its `expected_output`, its expectations, and the CONTENTS
1279
+ * of the input `files` fixtures vat stages into the arm's workspace and instructs
1280
+ * it to operate on. An adopter who quotes a sentence of their SKILL.md through any
1281
+ * of the four would otherwise stamp every run contaminated, including the arm that
1282
+ * merely read the input vat gave it — the skill's words arriving THROUGH vat are
1283
+ * not the arm reaching the skill. The fixture channel is the one that was missed,
1284
+ * and it is the worst of the four to get wrong: the summary attached to that
1285
+ * verdict tells the operator to go uninstall an ambient copy of the plugin that
1286
+ * does not exist.
1287
+ *
1288
+ * Fixtures are read from the STAGED workspace rather than the suite directory, for
1289
+ * the same reason the SKILL.md is: that is byte-for-byte what the arm was handed.
1290
+ *
1291
+ * Returns `[]` when the file cannot be read. A missing SKILL.md is not this
1292
+ * function's error to raise (staging has already validated the subject), and an
1293
+ * unarmed signal is reported as unarmed rather than claimed as clean. An
1294
+ * unreadable FIXTURE is skipped for the same reason, and errs the safe way: a
1295
+ * fixture we could not exclude can only make the signal noisier, never blinder.
1296
+ */
1297
+ export function resolveSkillContentNeedles(subjectStagedDir, evals,
1298
+ /**
1299
+ * Where the staged fixtures live, as the two inputs an arm root is DERIVED from
1300
+ * rather than as the pre-joined path itself — see {@link armWorkspaceRoot}. Taking
1301
+ * a bare `string` here let the call site pass `workspacesRoot` (one level too high,
1302
+ * so every `<id>/` fixture lookup missed) with the whole unit suite still green.
1303
+ *
1304
+ * The `with` arm, because the fixture BYTES are what matter and both arms are
1305
+ * staged from the same source — and `with` is the only arm every run mints.
1306
+ */
1307
+ workspaces) {
1308
+ const stagedWorkspaceRoot = armWorkspaceRoot(workspaces.workspacesRoot, workspaces.armDirs, 'with');
1309
+ const skillMdPath = safePath.join(subjectStagedDir, 'SKILL.md');
1310
+ let markdown;
1311
+ try {
1312
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path derived from vat's own staged dir
1313
+ markdown = readFileSync(skillMdPath, 'utf8');
1314
+ }
1315
+ catch {
1316
+ return [];
1317
+ }
1318
+ const excluded = evals
1319
+ .flatMap((entry) => [
1320
+ entry.prompt,
1321
+ entry.expected_output ?? '',
1322
+ ...entry.expectations,
1323
+ ...readStagedFixtures(stagedWorkspaceRoot, entry),
1324
+ ])
1325
+ .join('\n');
1326
+ return skillContentNeedles(markdown, excluded);
1327
+ }
1328
+ /**
1329
+ * The text of one eval's staged input files. Unreadable/binary fixtures are skipped.
1330
+ *
1331
+ * A declared `files[]` entry is legitimately a DIRECTORY — staging does `existsSync`
1332
+ * then a recursive `cpSync` — so this walks one. It used to call `readFileSync` on
1333
+ * the declared path, get `EISDIR`, and skip it: the directory's whole contents were
1334
+ * in the arm's cwd and absent from the exclusion set, and two runs differing ONLY in
1335
+ * whether the same bytes were declared as a file or as their parent directory
1336
+ * reported `contaminated: false` and `contaminated: true` (with a `skill-content`
1337
+ * hit, and a summary telling the operator to uninstall an ambient plugin copy that
1338
+ * does not exist).
1339
+ *
1340
+ * The `catch`'s old claim that skipping "errs the safe way: noisier, never blinder"
1341
+ * is BACKWARDS for this signal. Over-reporting is the harm here, because a false
1342
+ * `contaminated: true` is actioned by discarding a run that was fine. A fixture that
1343
+ * cannot be read is still skipped — there is nothing else to do with it — but that
1344
+ * is a cost, not a safety margin.
1345
+ */
1346
+ function readStagedFixtures(stagedWorkspaceRoot, entry) {
1347
+ const texts = [];
1348
+ for (const rel of entry.files ?? []) {
1349
+ const staged = safePath.joinUnderRoot(safePath.joinUnderRoot(stagedWorkspaceRoot, String(entry.id)), rel);
1350
+ collectFixtureText(staged, texts);
1351
+ }
1352
+ return texts;
1353
+ }
1354
+ /** Depth cap on the fixture walk — a symlink loop must not hang the run's setup. */
1355
+ const MAX_FIXTURE_WALK_DEPTH = 32;
1356
+ /** Append `path`'s text, recursing when it is a directory. Never throws. */
1357
+ function collectFixtureText(path, into, depth = 0) {
1358
+ if (depth > MAX_FIXTURE_WALK_DEPTH)
1359
+ return;
1360
+ let entries;
1361
+ try {
1362
+ // `withFileTypes` on the DIRECTORY is one syscall for the whole level, and
1363
+ // `lstat`-shaped: a symlinked entry reports as a link rather than as whatever it
1364
+ // points at, so the walk stays inside the bytes vat actually staged.
1365
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is contained under vat's own staged workspace
1366
+ entries = readdirSync(path, { withFileTypes: true });
1367
+ }
1368
+ catch {
1369
+ // Not a directory (the common case: a plain file fixture), or unreadable.
1370
+ try {
1371
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is contained under vat's own staged workspace
1372
+ into.push(readFileSync(path, 'utf8'));
1373
+ }
1374
+ catch {
1375
+ // Unreadable or binary — nothing to exclude, and not this function's error to raise.
1376
+ }
1377
+ return;
1378
+ }
1379
+ for (const child of entries) {
1380
+ if (!child.isFile() && !child.isDirectory())
1381
+ continue;
1382
+ collectFixtureText(safePath.joinUnderRoot(path, child.name), into, depth + 1);
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Build the detector input for the skill-absent arm. ONE builder, so the hits
1387
+ * written per eval and the `signals` list stamped on the run-level integrity
1388
+ * block are derived from the same values — a second construction site is how the
1389
+ * block would come to claim a detector the scan never actually ran.
1390
+ *
1391
+ * `transcript` and `evalId` are supplied per call; everything else is per-run.
1392
+ *
1393
+ * Takes a NARROWED context rather than the whole {@link EvalRunContext} so the
1394
+ * builder is directly unit-testable — the detector honours `armCwd` (dropping it
1395
+ * there kills three tests) while the wiring that SUPPLIES it was droppable green,
1396
+ * and a dropped `armCwd` anchors every relative climb one directory too high, where
1397
+ * no needle can see it.
1398
+ *
1399
+ * `evalId` is what turns the arm's workspace ROOT into the arm's actual starting
1400
+ * CWD (`<armWorkspaceDir>/<evalId>` — see {@link resolvePerEvalWorkspaceDir}).
1401
+ * The detector needs both and they are one directory apart: containment is judged
1402
+ * against the root (the arm's whole tree is its own), while the cwd walk must
1403
+ * start exactly where the executor did, or every relative climb in the transcript
1404
+ * resolves one level too high and lands where no needle can see it.
1405
+ *
1406
+ * `evalId` is REQUIRED here, and the signals-only caller gets its own entry point
1407
+ * ({@link buildContaminationSignalsInput}) rather than sharing an optional third
1408
+ * argument. An optional parameter is a pin the compiler cannot hold: dropping the
1409
+ * argument at the SCAN call site left all 2270 unit tests green and was caught only
1410
+ * by the integration suite — which this repo's fast implementer loop explicitly says
1411
+ * not to run. That is the same shape as the dropped `armCwd` that anchored the whole
1412
+ * walk one directory too high, also green.
1413
+ */
1414
+ export function buildContaminationInput(transcript, ctx, evalId) {
1415
+ return contaminationInput(transcript, ctx, evalId);
1416
+ }
1417
+ /**
1418
+ * The same input built for the "which detectors are ARMED?" question, which is asked
1419
+ * once per run with no transcript and no eval — so it can honestly supply neither,
1420
+ * and `armCwd` is correctly absent rather than accidentally dropped.
1421
+ */
1422
+ export function buildContaminationSignalsInput(ctx) {
1423
+ return contaminationInput('', ctx, undefined);
1424
+ }
1425
+ function contaminationInput(transcript, ctx, evalId) {
1426
+ const armRoot = ctx.armDirs.without === undefined || ctx.armDirs.without === ''
1427
+ ? undefined
1428
+ : armWorkspaceRoot(ctx.workspacesRoot, ctx.armDirs, 'without');
1429
+ return {
1430
+ transcript,
1431
+ harnessRoot: ctx.harnessRoot,
1432
+ // Only ever called for the skill-absent arm, so the sibling is `with` — the
1433
+ // treatment arm's live working directory, one `ls ..` away and containing no
1434
+ // harness path for the needles above to find.
1435
+ siblingArmDir: armWorkspaceRoot(ctx.workspacesRoot, ctx.armDirs, 'with'),
1436
+ // The control arm's OWN root, the exact counterpart of the line above. Its
1437
+ // absolute path is stated in the arm's own prompt, so the arm reuses it
1438
+ // constantly; without this the executable-name signal reads every mention of
1439
+ // the arm's own scratch script as a reach into the skill.
1440
+ //
1441
+ // Conditional because this builder also serves `buildContaminationSignalsInput`,
1442
+ // which runs on EVERY run — and a non-baseline run mints no `without` arm, so
1443
+ // `armDirSegment` would (correctly) throw rather than invent a segment.
1444
+ ...(armRoot === undefined ? {} : { armWorkspaceDir: armRoot }),
1445
+ // ...and the eval's own subdirectory under it, which is where the executor was
1446
+ // actually spawned. Absent without an evalId, in which case the walk anchors
1447
+ // at the root — one level high, but the best anchor available.
1448
+ ...(armRoot === undefined || evalId === undefined
1449
+ ? {}
1450
+ : { armCwd: safePath.joinUnderRoot(armRoot, evalId) }),
1451
+ // VAT's private tmp dirs: the held eval suite (the `expected_output` answer
1452
+ // key) and the grader dir (the run's integrity nonce). Both are SIBLINGS of
1453
+ // the arm's cwd under the OS temp dir — two hops or one `$TMPDIR` expansion
1454
+ // away — and contain no harness path, so neither the harness needles nor the
1455
+ // sibling-arm needles can see them. The workspaces root is deliberately NOT
1456
+ // in this list: it is the arm's own legitimate cwd.
1457
+ vatPrivateDirs: [ctx.evalSuiteHoldDir, ctx.graderOutDir],
1458
+ skillContentNeedles: ctx.skillContentNeedles,
1459
+ // The skill-relative PATH (`scripts/csvsum.py`), not the extension-stripped
1460
+ // `name`. An ambient copy of the skill is a COPY, so it reproduces the skill's
1461
+ // internal layout under whatever root it sits at and still matches; the control
1462
+ // arm's own `/tmp/summary.txt` no longer does. `howInvoked` is a command string,
1463
+ // not a path, so it is deliberately NOT used here.
1464
+ ...(ctx.declaredExecutables === undefined
1465
+ ? {}
1466
+ : { executablePaths: ctx.declaredExecutables.map((e) => e.path) }),
1467
+ };
1468
+ }
1469
+ function baselineContaminationFor(transcript, ctx, evalId) {
1470
+ const { hits, degraded } = detectBaselineContamination(buildContaminationInput(transcript, ctx, evalId));
1471
+ // A degraded scan writes `contaminated: false` with exactly the same bytes as one
1472
+ // that walked the transcript properly, so silence here would be the "quietly wrong
1473
+ // measurement" this whole module exists to prevent. It goes to BOTH surfaces, and
1474
+ // neither substitutes for the other:
1475
+ // stderr reaches the operator watching the run, and the fragment carries it to
1476
+ // `baselineIntegrity.degraded` in baseline.json, which is what a reader has
1477
+ // months later when the stderr scrollback is gone.
1478
+ //
1479
+ // The `evalId` is stamped HERE rather than by the detector: the detector is pure
1480
+ // and scans one transcript with no idea which eval it belongs to, and an
1481
+ // unattributed degradation in a run-level list is unactionable.
1482
+ if (degraded !== undefined) {
1483
+ process.stderr.write(`⚠️ control arm (${evalId}): contamination scan DEGRADED (${degraded.reason}) — ${degraded.detail}. ` +
1484
+ 'A clean verdict from this eval is weaker evidence than a clean structured scan: the walk ' +
1485
+ 'either stopped resolving paths from this point on, or (for an unparseable transcript) fell ' +
1486
+ 'back to flat text matching, which both over- and under-reports.\n');
1487
+ }
1488
+ return {
1489
+ ...(hits.length === 0 ? {} : { contamination: hits }),
1490
+ ...(degraded === undefined ? {} : { degraded: { ...degraded, evalId } }),
1491
+ };
1492
+ }
1493
+ /**
1494
+ * Which detectors this run armed, for the `signals` field on the integrity block.
1495
+ * Derived from {@link buildContaminationInput} with an empty transcript — the
1496
+ * armed set is a property of the run's paths, never of any one transcript.
1497
+ */
1498
+ function contaminationSignalsFor(ctx) {
1499
+ return activeContaminationSignals(buildContaminationSignalsInput(ctx));
807
1500
  }
808
1501
  /**
809
1502
  * Run evals TIER by TIER (ascending), bounded-parallel WITHIN each tier, with a
@@ -820,9 +1513,16 @@ async function runEvalWorker(item, ctx) {
820
1513
  async function runEvalsTiered(input) {
821
1514
  const groups = groupEvalsByTier(input.evals);
822
1515
  const fragments = [];
1516
+ const controlFailures = [];
823
1517
  for (const [index, group] of groups.entries()) {
824
- const tierFragments = await input.runTier(buildEvalWorkItems(group.evals, input.baseline));
1518
+ const outcomes = await input.runTier(buildEvalWorkItems(group.evals, input.baseline));
1519
+ const tierFragments = outcomes.flatMap((o) => (o.fragment === undefined ? [] : [o.fragment]));
1520
+ controlFailures.push(...outcomes.flatMap((o) => (o.controlFailure === undefined ? [] : [o.controlFailure])));
825
1521
  fragments.push(...tierFragments);
1522
+ // A control-arm failure deliberately does NOT gate. The gate asks whether the
1523
+ // SKILL failed a cheap expectation before spending on expensive ones, and the
1524
+ // control arm is not the skill — treating its executor timeout as a gating
1525
+ // failure would stop a run whose treatment tier passed everything.
826
1526
  const withArm = tierFragments.filter((f) => f.arm !== 'without');
827
1527
  if (!shouldGateAfterTier(withArm))
828
1528
  continue;
@@ -833,9 +1533,9 @@ async function runEvalsTiered(input) {
833
1533
  // Legibility (required): name the skipped tiers on stderr so a fail-fast run is
834
1534
  // never mistaken for a smaller passing suite. stdout stays machine-readable.
835
1535
  process.stderr.write(formatSkippedTiersSummary(skipped) + '\n');
836
- return { fragments, skipped };
1536
+ return { fragments, controlFailures, skipped };
837
1537
  }
838
- return { fragments };
1538
+ return { fragments, controlFailures };
839
1539
  }
840
1540
  /** Resolve the run's grading/friction/baseline/tool-eval artifact paths under
841
1541
  * `resultsDir`. Single source of truth for the filenames (used by the
@@ -858,10 +1558,52 @@ export function resolveArtifactPaths(resultsDir) {
858
1558
  * (`force: true`) — a missing file is fine.
859
1559
  */
860
1560
  export function wipeStaleArtifacts(paths) {
861
- rmSync(paths.gradingOut, { force: true });
862
- rmSync(paths.frictionOut, { force: true });
863
- rmSync(paths.baselineOut, { force: true });
864
- rmSync(paths.toolEvalOut, { force: true });
1561
+ for (const path of [paths.gradingOut, paths.frictionOut, paths.baselineOut, paths.toolEvalOut]) {
1562
+ rmSync(path, { force: true });
1563
+ // ...and the quarantined copy a PRIOR run's failed gate set aside. It is just as
1564
+ // stale, and left alone `results/` accumulates one `.rejected` per broken run
1565
+ // with nothing to say which is the newest.
1566
+ rmSync(rejectedArtifactPath(path), { force: true });
1567
+ }
1568
+ }
1569
+ /**
1570
+ * Create `results/` and write this run's provenance — unless this is a DRY RUN, in
1571
+ * which case it does nothing at all.
1572
+ *
1573
+ * NOTHING under `results/` may be created, written, or removed by a dry run. The
1574
+ * harness root is a deterministic function of the subject, so `results/` here holds
1575
+ * the PREVIOUS run's output: creating the dir, rewriting `provenance.json`, and (at
1576
+ * the call site) wiping the stale artifacts meant "what would this cost next time?"
1577
+ * destroyed the run the operator was about to read. Measured twice: a real
1578
+ * `--baseline` run left `[baseline, friction, grading, provenance, tool-eval].json`
1579
+ * and a subsequent `--dry-run` against the same subject left `[provenance.json]`.
1580
+ *
1581
+ * A function rather than an `if` at the call site because the orchestrator is at its
1582
+ * cognitive-complexity ceiling; the RULE is what matters and it is written here.
1583
+ */
1584
+ function openResultsDir(input) {
1585
+ if (input.dryRun)
1586
+ return;
1587
+ // 0700, matching the harness root. `grading.json` quotes the executor transcript
1588
+ // verbatim in each expectation's evidence, so whatever the skill read out of its
1589
+ // input files ends up here as text — and unlike the workspaces, results/ SURVIVES
1590
+ // `--keep` by design.
1591
+ mkdirSyncReal(input.resultsDir, { recursive: true, mode: 0o700 });
1592
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1593
+ writeFileSync(input.provenancePath, JSON.stringify(input.provenance, null, 2) + '\n', 'utf-8');
1594
+ }
1595
+ /**
1596
+ * Where a results artifact goes when it fails the fail-closed gate.
1597
+ *
1598
+ * The gate throws and `results/` is deliberately RETAINED, so the file that failed
1599
+ * used to sit there under its authoritative name — the one artifact whose schema
1600
+ * violation is BY DEFINITION a harness bug, indistinguishable in a CI archive from a
1601
+ * good one until the next run's wipe. Renamed rather than deleted: the bytes are what
1602
+ * a maintainer needs in order to see which refine tripped, and a name ending in
1603
+ * `.rejected` cannot be mistaken for the artifact a reader came for.
1604
+ */
1605
+ export function rejectedArtifactPath(path) {
1606
+ return `${path}.rejected`;
865
1607
  }
866
1608
  /**
867
1609
  * Merge the run's grader fragments and WRITE the run artifacts (vat is the SOLE
@@ -875,9 +1617,118 @@ export function wipeStaleArtifacts(paths) {
875
1617
  * can compute the COMPOSITE verdict. Every fragment's per-run nonce is re-verified
876
1618
  * inside {@link mergeFragmentsToGrading}.
877
1619
  */
878
- function writeRunArtifactsAndReconcile(fragments, runNonce, paths) {
1620
+ /**
1621
+ * Collect per-eval baseline-integrity findings from the WITHOUT-arm fragments.
1622
+ * Fragments with no `contamination` are clean and contribute nothing. Pure.
1623
+ */
1624
+ /**
1625
+ * What one arm graded, per eval — the SINGLE derivation behind both the parity check
1626
+ * ({@link armExpectationSkew}, which reads `total`) and the A/B delta (which reads
1627
+ * `passed`). Deriving those separately would let the two blocks in `baseline.json`
1628
+ * disagree about the same arm. Pure.
1629
+ *
1630
+ * Exported for its own unit test, not for reuse. Setting `passed` to `total` here
1631
+ * failed zero unit tests and three integration ones: every unit fixture graded
1632
+ * everything it declared, so the two fields were numerically identical in all of
1633
+ * them and the ONE thing this function exists to keep distinct was unpinned.
1634
+ */
1635
+ export function gradedCounts(fragments) {
1636
+ return fragments.map((f) => ({
1637
+ evalId: f.evalId,
1638
+ passed: f.expectations.filter((e) => e.passed).length,
1639
+ total: f.expectations.length,
1640
+ }));
1641
+ }
1642
+ /**
1643
+ * Both per-eval integrity channels the WITHOUT-arm fragments carry, lifted in ONE
1644
+ * pass: what the scan FOUND (`findings`) and how well it was able to look
1645
+ * (`degraded`).
1646
+ *
1647
+ * Together, not separately, because they are read together: `contaminated: false`
1648
+ * means "looked properly and found nothing" only when `degraded` is empty, and a
1649
+ * caller that collects one and forgets the other publishes the first half of that
1650
+ * sentence as if it were the whole of it. That is not hypothetical — the degradation
1651
+ * was detected, schema'd, summarised and rendered, and then never collected, so
1652
+ * every `baseline.json` shipped claiming a clean scan it had not necessarily run.
1653
+ */
1654
+ function collectBaselineFindings(withoutArm) {
1655
+ return {
1656
+ findings: withoutArm
1657
+ .filter((f) => f.contamination !== undefined && f.contamination.length > 0)
1658
+ .map((f) => ({ evalId: f.evalId, hits: f.contamination })),
1659
+ degraded: withoutArm.flatMap((f) => (f.degraded === undefined ? [] : [f.degraded])),
1660
+ };
1661
+ }
1662
+ /**
1663
+ * Restate a {@link SkippedEvalsSummary} as the delta block's truncation record.
1664
+ *
1665
+ * A separate shape rather than embedding the tier summary wholesale: the delta cares
1666
+ * about WHAT IS MISSING FROM ITS TOTALS, which is the flat set of evals that never
1667
+ * ran on either arm, while `SkippedEvalsSummary` is organised for the tier reporter.
1668
+ * Pure.
1669
+ */
1670
+ function deltaTruncationFor(skipped) {
1671
+ if (skipped === undefined || skipped.totalSkipped === 0)
1672
+ return null;
1673
+ return {
1674
+ gatedByTier: skipped.gatedByTier,
1675
+ firstSkippedTier: skipped.firstSkippedTier,
1676
+ totalSkipped: skipped.totalSkipped,
1677
+ evalIds: skipped.tiers.flatMap((tier) => tier.evalIds),
1678
+ };
1679
+ }
1680
+ /**
1681
+ * The stderr report for a `--baseline` run: the delta line, then the ⚠️ banner when
1682
+ * the run is unusable. Pure — the caller decides WHEN it goes out, which is the
1683
+ * whole point (see the call site).
1684
+ *
1685
+ * Composed on EVERY baseline run, including a withheld one: "the delta is
1686
+ * unavailable, and here is why" is the report an operator needs, and a run that
1687
+ * silently prints nothing is indistinguishable from a vat that still does not
1688
+ * compute a delta at all. The delta line comes FIRST so an unusable run reads as the
1689
+ * number followed by the caveat that explains it — the banner is the only line that
1690
+ * says the number means nothing, so it must never be the one scrolled away.
1691
+ *
1692
+ * ⚠️ IT IS NOT LITERALLY LAST ON THE SCREEN, and this docblock used to say it was.
1693
+ * The CLI prints `Harness:`, `Results:`, `Workspaces:` and `Summary:` AFTER the
1694
+ * harness returns, so 2–4 lines always follow the banner. What this ordering buys is
1695
+ * that the banner is not separated from its delta by up to ~1250 rows of friction —
1696
+ * a bounded, short tail after it, not none. Moving those CLI lines above the harness
1697
+ * report is the CLI's call, not this module's.
1698
+ *
1699
+ * THREE conditions have to hold before the caveat is dropped, not two. A DEGRADED
1700
+ * scan is neither contaminated nor incomparable, so a two-clause gate printed
1701
+ * `Baseline delta: +0 (with skill: 2/2, without skill: 2/2).` and exited 0 while
1702
+ * `baseline.json` carried `degraded: [...]` and an `integrity.summary` ending in a
1703
+ * `⚠️ DEGRADED SCAN: …` sentence that was composed, written to the artifact, and
1704
+ * never emitted anywhere. Everything this module says about telling "checked and clean"
1705
+ * apart from "checked with the blunt instrument" was true of the artifact and false
1706
+ * of the terminal — and the terminal is the only surface an operator reads.
1707
+ *
1708
+ * A CONTAMINATED run still reports its number, deliberately. Contamination does not
1709
+ * make the subtraction illegal — both arms were graded to the same depth, so the
1710
+ * arithmetic is sound; what it invalidates is INTERPRETING that number as skill
1711
+ * lift, which is precisely what the ⚠️ line says ("The reported delta is NOT a
1712
+ * measure of skill lift"), and what `vat-skill-testing.md` turns into the
1713
+ * instruction "discard the delta". Withholding it here would (a) leave that sentence
1714
+ * referring to a number that was never reported, and (b) spend `null` — which means
1715
+ * "these arms cannot be subtracted", a different failure with a different remedy —
1716
+ * on a run where they can be. A contaminated delta is also diagnostic in its own
1717
+ * right: it collapses toward zero, which is corroboration, not noise.
1718
+ */
1719
+ export function formatBaselineReport(delta, integrity) {
1720
+ const line = `\n${formatBaselineDeltaLine(delta)}\n`;
1721
+ // Any of the three makes the delta unusable-as-stated, and all three are only
1722
+ // visible on stderr — nobody opens baseline.json unprompted.
1723
+ if (!integrity.contaminated && integrity.comparable && integrity.degraded.length === 0)
1724
+ return line;
1725
+ return `${line}\n⚠️ ${integrity.summary}\n\n`;
1726
+ }
1727
+ function writeRunArtifactsAndReconcile(input) {
1728
+ const { fragments, runNonce, paths, signals, controlFailures } = input;
879
1729
  const { withArm, withoutArm } = partitionFragmentsByArm(fragments);
880
- const grading = mergeFragmentsToGrading(withArm, runNonce);
1730
+ let baselineReport;
1731
+ const grading = mergeFragmentsToGrading(withArm, runNonce, 'with');
881
1732
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
882
1733
  writeFileSync(paths.gradingOut, JSON.stringify(grading, null, 2) + '\n', 'utf-8');
883
1734
  const friction = mergeFragmentsToFriction(fragments);
@@ -888,12 +1739,104 @@ function writeRunArtifactsAndReconcile(fragments, runNonce, paths) {
888
1739
  const toolEval = mergeFragmentsToToolEval(withArm);
889
1740
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
890
1741
  writeFileSync(paths.toolEvalOut, JSON.stringify(toolEval, null, 2) + '\n', 'utf-8');
891
- if (withoutArm.length > 0) {
892
- const baseline = mergeFragmentsToGrading(withoutArm, runNonce);
1742
+ // `controlFailures` is the second disjunct, and it is load-bearing: when EVERY
1743
+ // control eval died there are no WITHOUT-arm fragments at all, and gating on the
1744
+ // fragment count alone would write no baseline.json — so the one run that most
1745
+ // needs to say "the control arm did not happen" would say nothing, exactly like a
1746
+ // run where `--baseline` was never passed. `mergeFragmentsToGrading` over an empty
1747
+ // arm yields a zero/zero report, which is the honest shape for it.
1748
+ if (withoutArm.length > 0 || controlFailures.length > 0) {
1749
+ const baseline = mergeFragmentsToGrading(withoutArm, runNonce, 'without');
1750
+ // Stamp the integrity verdict onto baseline.json (GradingReportSchema is
1751
+ // .passthrough(), so the extra field is contract-legal). Written on EVERY
1752
+ // baseline run, clean or not: a reader must be able to tell "checked and
1753
+ // clean" from "produced before this check existed", and only an
1754
+ // unconditional field does that.
1755
+ // Parity BEFORE the verdict prose, because it decides whether the number the
1756
+ // prose is about means anything. Deliberately NOT a throw: the control arm's
1757
+ // grader misbehaving must not discard a perfectly good treatment result — it
1758
+ // must make the DELTA say, in writing, that it cannot be subtracted.
1759
+ // ONE derivation per arm, feeding BOTH the parity check and the delta. The
1760
+ // whole reason `ArmEvalGrade` carries `passed` alongside `total` is that these
1761
+ // two blocks must provably be talking about the same numbers — re-deriving the
1762
+ // counts for the delta would let `baselineIntegrity.skew` and `baselineDelta`
1763
+ // disagree about one run, and a reader holding two contradicting blocks has no
1764
+ // way to tell which one to believe.
1765
+ const withGrades = gradedCounts(withArm);
1766
+ const withoutGrades = gradedCounts(withoutArm);
1767
+ const skew = armExpectationSkew(withGrades, withoutGrades);
1768
+ const { findings, degraded } = collectBaselineFindings(withoutArm);
1769
+ const integrity = summarizeBaselineIntegrity({
1770
+ findings,
1771
+ // WHAT WAS SCANNED, not what was armed. `signals` is derived from the run's
1772
+ // PATHS, so it is fully populated on a run where every control eval died before
1773
+ // producing a transcript — and the shipped documentation teaches `signals` as
1774
+ // exactly the discriminator between a clean verdict and a blind one ("an empty
1775
+ // list means nothing was looking"). A control-timeout run wrote
1776
+ // `"contaminated": false` alongside `checked by: harness-path, sibling-arm,
1777
+ // vat-private-dir, skill-content` having scanned zero transcripts: the list was
1778
+ // full and nothing was looking. With no control fragment there is no
1779
+ // observation, so there is no detector to claim.
1780
+ signals: withoutArm.length === 0 ? [] : signals,
1781
+ // The same fact `signals` is gated on, stated as a number rather than inferred
1782
+ // from an empty list — it gates the VERDICT PROSE, which `signals` cannot reach.
1783
+ // Without it the summary opened "No skill-absent eval was observed reaching the
1784
+ // skill. The A/B delta is interpretable as instruction lift", which is a positive
1785
+ // claim about an observation that never happened, with the correction third.
1786
+ // Required, not defaulted, for the same reason `signals` is: a default lets every
1787
+ // caller overclaim in the "clean" direction, which is where it gets believed.
1788
+ // Control evals whose transcript actually reached the scanner — NOT evals declared.
1789
+ observedEvals: withoutArm.length,
1790
+ skew,
1791
+ degraded,
1792
+ controlArmFailures: controlFailures,
1793
+ });
1794
+ // The SUBTRACTION `--baseline` exists to perform, and until this it was never
1795
+ // performed anywhere: vat wrote two same-shaped artifacts and left the operator
1796
+ // to do the arithmetic by hand while the docs said it "reports the delta".
1797
+ // `skew` is passed rather than re-checked so the withholding decision has
1798
+ // exactly one author.
1799
+ // `truncated` rides along rather than withholding: fail-fast stops BOTH arms at
1800
+ // the same tier boundary, so the arithmetic over the tiers that ran is sound —
1801
+ // what it changes is the SCOPE of the claim. Without it, a tier-0 failure in an
1802
+ // 8-eval suite printed `+1 (with skill: 2/2, without skill: 1/2)`, which is
1803
+ // byte-identical to a complete 2-eval suite. And this is the COMMON case, not a
1804
+ // corner: `--baseline` exists to measure a skill that may not be helping, so a
1805
+ // WITH-arm failure — the thing that fires the gate — is the expected state of an
1806
+ // interesting baseline run.
1807
+ const delta = computeBaselineDelta({
1808
+ withArm: withGrades,
1809
+ withoutArm: withoutGrades,
1810
+ skew,
1811
+ controlArmFailures: controlFailures,
1812
+ truncated: deltaTruncationFor(input.skipped),
1813
+ });
1814
+ // A SIBLING of `baselineIntegrity`, not a field inside it, because they answer
1815
+ // different questions: integrity answers "may I subtract these two arms?", and
1816
+ // the delta IS the subtraction. Burying the number inside the block that
1817
+ // qualifies it would also mean a reader who wants only the lift has to
1818
+ // understand the contamination vocabulary first. (GradingReportSchema is
1819
+ // .passthrough(), so both extra keys are contract-legal — see the comment above.)
893
1820
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
894
- writeFileSync(paths.baselineOut, JSON.stringify(baseline, null, 2) + '\n', 'utf-8');
1821
+ writeFileSync(paths.baselineOut, JSON.stringify({ ...baseline, baselineIntegrity: integrity, baselineDelta: delta }, null, 2) + '\n', 'utf-8');
1822
+ // RETURNED, not written. The delta line and the ⚠️ banner must not be separated
1823
+ // from each other by the friction report: `emitFrictionReport` runs from the
1824
+ // harness `finally`, long after this point, and its two caps (50 lines × 2000
1825
+ // chars) allow 101,245 characters — about 1250 rows of an 80-column terminal —
1826
+ // between the banner and the prompt. The banner is the only line that says the
1827
+ // number above it is meaningless, so pushing it off-screen is the same failure as
1828
+ // not printing it. Handing the text back lets the caller emit friction FIRST and
1829
+ // this after it; a total-output budget on friction would only shorten the
1830
+ // distance, not end it. (The banner is not the LAST line of the process's output
1831
+ // — the CLI adds `Harness:`/`Results:`/`Workspaces:`/`Summary:` after the harness
1832
+ // returns. That tail is 2–4 lines and bounded, which is the point.)
1833
+ baselineReport = formatBaselineReport(delta, integrity);
895
1834
  }
896
- return { verdict: reconcileGrading(grading), toolEval };
1835
+ return {
1836
+ verdict: reconcileGrading(grading),
1837
+ toolEval,
1838
+ ...(baselineReport === undefined ? {} : { baselineReport }),
1839
+ };
897
1840
  }
898
1841
  /**
899
1842
  * The COMPOSITE run verdict (issue #145 Phase T): the run passes only when BOTH
@@ -976,25 +1919,75 @@ function assertVatWroteArtifact(path, validate, label) {
976
1919
  raw = JSON.parse(readFileSync(path, 'utf-8'));
977
1920
  }
978
1921
  catch (err) {
979
- throw new InternalHarnessError(`vat-written ${label} at ${path} is not valid JSON (harness bug): ${err instanceof Error ? err.message : String(err)}`);
1922
+ throw new InternalHarnessError(`vat-written ${label} at ${path} is not valid JSON (harness bug)${quarantineArtifact(path)}: ` +
1923
+ `${err instanceof Error ? err.message : String(err)}`);
980
1924
  }
981
1925
  try {
982
1926
  validate(raw);
983
1927
  }
984
1928
  catch (err) {
985
- throw new InternalHarnessError(`vat-written ${label} at ${path} failed its schema (harness bug): ${err instanceof Error ? err.message : String(err)}`);
1929
+ throw new InternalHarnessError(`vat-written ${label} at ${path} failed its schema (harness bug)${quarantineArtifact(path)}: ` +
1930
+ `${err instanceof Error ? err.message : String(err)}`);
1931
+ }
1932
+ }
1933
+ /**
1934
+ * Move a gate-failing artifact aside and say where it went. See
1935
+ * {@link rejectedArtifactPath}.
1936
+ *
1937
+ * Best-effort and never throws: this runs on the way OUT of a run that is already
1938
+ * failing, and a cleanup that replaces the real diagnosis with a rename error would
1939
+ * be strictly worse than a file left in place. If the rename fails the file is
1940
+ * removed instead — an absent artifact reads as "vat wrote nothing", which is at
1941
+ * least not a false claim of authority.
1942
+ */
1943
+ function quarantineArtifact(path) {
1944
+ const rejected = rejectedArtifactPath(path);
1945
+ try {
1946
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1947
+ renameSync(path, rejected);
1948
+ return `; moved aside to ${rejected} so it is not read as authoritative`;
1949
+ }
1950
+ catch {
1951
+ try {
1952
+ rmSync(path, { force: true });
1953
+ return '; removed so it is not read as authoritative';
1954
+ }
1955
+ catch {
1956
+ return '';
1957
+ }
986
1958
  }
987
1959
  }
988
1960
  /**
989
1961
  * Post-merge D2 gate over the vat-written results artifacts (vat is SOLE writer):
990
- * grading.json, friction.json, and tool-eval.json must each exist + parse + validate.
991
- * A separate, explicit check (NOT folded into any prompt-invariant linter) — fail-closed
992
- * and safe because vat wrote every one of these itself just above.
1962
+ * grading.json, friction.json, tool-eval.json — and, on a baseline run, baseline.json
1963
+ * — must each exist + parse + validate. A separate, explicit check (NOT folded into
1964
+ * any prompt-invariant linter) — fail-closed and safe because vat wrote every one of
1965
+ * these itself just above.
1966
+ *
1967
+ * `baseline.json` was EXEMPT from this gate for as long as the gate has existed, and
1968
+ * it is the artifact the exemption fits worst: on a `--baseline` run it is the only
1969
+ * durable record of the thing the command sells, and this branch has already been
1970
+ * bitten once by a default run deleting `results/` out from under it. Its validation
1971
+ * is also the strictest of the four — {@link BaselineArtifactSchema} carries the
1972
+ * `passed <= total` and `delta === with − without` refines, so a merge that produced
1973
+ * a self-contradicting number fails the run here instead of being written down.
1974
+ *
1975
+ * `baselineRun` gates it rather than "does the file exist", deliberately: an
1976
+ * existence check would pass on the one failure that matters (vat asked for a
1977
+ * baseline and wrote nothing), which is exactly what a fail-CLOSED gate must not do.
1978
+ * A non-baseline run writes no baseline.json at all and is not asked about one.
993
1979
  */
994
- function assertVatWroteArtifacts(paths) {
1980
+ export function assertVatWroteArtifacts(paths, baselineRun) {
995
1981
  assertVatWroteArtifact(paths.gradingOut, (raw) => { GradingReportSchema.parse(raw); }, 'grading.json');
996
1982
  assertVatWroteArtifact(paths.frictionOut, (raw) => { FrictionReportSchema.parse(raw); }, 'friction.json');
997
1983
  assertVatWroteArtifact(paths.toolEvalOut, (raw) => { ToolEvalReportSchema.parse(raw); }, 'tool-eval.json');
1984
+ if (!baselineRun)
1985
+ return;
1986
+ assertVatWroteArtifact(paths.baselineOut, (raw) => {
1987
+ // Both halves: the grading report it IS, and the two blocks vat stamps onto it.
1988
+ GradingReportSchema.parse(raw);
1989
+ BaselineArtifactSchema.parse(raw);
1990
+ }, 'baseline.json');
998
1991
  }
999
1992
  // ---------------------------------------------------------------------------
1000
1993
  // Main orchestrator
@@ -1003,7 +1996,23 @@ function assertVatWroteArtifacts(paths) {
1003
1996
  * Domain orchestrator for `vat skill test run`. Pure of process.exit — all
1004
1997
  * exit-code decisions live in the caller (run.ts).
1005
1998
  */
1006
- export async function runSkillTestHarness(opts) {
1999
+ /**
2000
+ * Resolve WHERE this run lives, and whether the harness owns that location.
2001
+ *
2002
+ * `--out` names the harness root exactly; `--workdir` names the BASE the root is
2003
+ * derived under. They are mutually exclusive: passing both used to silently
2004
+ * discard `--workdir` — the run went entirely to `--out` and the `--workdir` path
2005
+ * was never even created — so an operator using the pair to separate the
2006
+ * executor's cwd from the staged trees got no separation and no warning.
2007
+ *
2008
+ * `harnessCreated` is true only for the derived-under-OS-tmp case; an explicit
2009
+ * `--out` or `--workdir` is a location the user owns and is never auto-removed.
2010
+ */
2011
+ export function resolveHarnessLocation(opts) {
2012
+ if (opts.out !== undefined && opts.workdir !== undefined) {
2013
+ throw new HarnessLocationError('--out and --workdir are mutually exclusive: --out names the harness root exactly, ' +
2014
+ '--workdir names the base it is derived under. Pass one.');
2015
+ }
1007
2016
  // §7 workdir safety: refuse a --workdir whose ancestry contains CLAUDE.md/.claude
1008
2017
  // BEFORE deriving the harness root from it (defense in depth with --setting-sources "").
1009
2018
  if (opts.workdir !== undefined) {
@@ -1024,11 +2033,19 @@ export async function runSkillTestHarness(opts) {
1024
2033
  // caught, because the walk inspects `$HOME/proj` and stops only at `$HOME` itself.
1025
2034
  assertSafeWorkdir(opts.workdir, homedir());
1026
2035
  }
1027
- const harnessRoot = opts.out ?? resolveHarnessRoot([opts.subject], opts.workdir);
1028
- // Only auto-remove the dir the harness itself created under OS tmp. An explicit
1029
- // --out (exact dir) or --workdir (user-chosen base) is a location the user owns;
1030
- // treat it like --keep and never delete it.
1031
- const harnessCreated = opts.out === undefined && opts.workdir === undefined;
2036
+ return {
2037
+ // `--out` is RESOLVED to an absolute path, not taken verbatim. A relative
2038
+ // `--out ./h` used to be stored as `./h` and then used as a contamination
2039
+ // needle — a two-character string that matches almost any transcript, so every
2040
+ // eval reported contaminated and the operator was told to discard a clean run.
2041
+ harnessRoot: opts.out === undefined
2042
+ ? resolveHarnessRoot([opts.subject], opts.workdir)
2043
+ : safePath.resolve(opts.out),
2044
+ harnessCreated: opts.out === undefined && opts.workdir === undefined,
2045
+ };
2046
+ }
2047
+ export async function runSkillTestHarness(opts) {
2048
+ const { harnessRoot, harnessCreated } = resolveHarnessLocation(opts);
1032
2049
  const repoRoot = opts.repoRoot ?? harnessRoot;
1033
2050
  // Two distinct questions, deliberately not one value. `evalsRef` is what the
1034
2051
  // adopter ASKED FOR — a `test.evals`/`--evals` value, or `undefined` for the
@@ -1071,6 +2088,31 @@ export async function runSkillTestHarness(opts) {
1071
2088
  // when the subject carried no suite, which is the common case) so the staging call
1072
2089
  // stays branch-free; removed by the same cleanup as the grader dir.
1073
2090
  const evalSuiteHoldDir = resolveEvalSuiteHoldDir(randomBytes(16).toString('hex'));
2091
+ // The executor's per-eval working directories. Like the grader and hold dirs
2092
+ // these live OUTSIDE harnessRoot — see resolveWorkspacesRoot for why that
2093
+ // separation is load-bearing for --baseline — so they need their own cleanup.
2094
+ const workspacesRoot = resolveWorkspacesRoot(randomBytes(16).toString('hex'));
2095
+ // Whether those workspaces OUTLIVE the run. `--keep` is the ONLY thing that
2096
+ // retains them.
2097
+ //
2098
+ // Deliberately NOT mirrored on `cleanupHarness`'s rule (keep OR user-owned
2099
+ // location). That rule exists because `--out`/`--workdir` name a directory the
2100
+ // USER owns, so vat has no business deleting it. Since these moved out of the
2101
+ // harness root they live under OS tmp, which the user did not choose and does
2102
+ // not manage — carrying the rule across would orphan a
2103
+ // `vat-skill-test-ws-<token>` dir on every `--out` run, forever. The location
2104
+ // changed, so the retention policy has to change with it.
2105
+ //
2106
+ // Decided ONCE, here, because two consumers ask the question — cleanup (delete
2107
+ // or not) and the returned `workspacesPath` (report or not) — and a second copy
2108
+ // of the predicate would drift, which is exactly how the result came to name a
2109
+ // directory cleanup had already removed.
2110
+ const retainWorkspaces = opts.keep === true;
2111
+ // The opaque per-arm segments beneath it. Minted here, with the roots, so the
2112
+ // same values reach staging, the executor's cwd, and the contamination
2113
+ // detector's sibling-arm needle — one source of truth for a value whose whole
2114
+ // job is to be unguessable and to carry no meaning.
2115
+ const armDirs = mintArmWorkspaceDirs(opts.baseline === true);
1074
2116
  const cleanup = () => {
1075
2117
  // Reap any still-in-flight executor/grader children FIRST. On the concurrent
1076
2118
  // error path `Promise.all` rejects without cancelling its siblings, and a
@@ -1080,7 +2122,13 @@ export async function runSkillTestHarness(opts) {
1080
2122
  // NOTE: the registry is a module-level singleton, so this assumes one run per
1081
2123
  // process (true for the CLI). A library embedding two concurrent runs in one
1082
2124
  // process would need per-run child scoping instead.
1083
- killAllActiveClaudeChildren();
2125
+ //
2126
+ // `cleanupHarness`, `removeVatOnlyDir` and `lock.release()` each swallow
2127
+ // explicitly at their own definition, for the stated reason that cleanup "must
2128
+ // not mask the run's real outcome". `killAllActiveClaudeChildren` lives in
2129
+ // another package, so its guard is applied here — see {@link swallowCleanupFailure}
2130
+ // for why a call-site guard is the weaker of the two forms.
2131
+ swallowCleanupFailure(killAllActiveClaudeChildren);
1084
2132
  lock.release();
1085
2133
  cleanupHarness(harnessRoot, { keep: opts.keep === true, created: harnessCreated });
1086
2134
  removeVatOnlyDir(graderOutDir);
@@ -1088,6 +2136,10 @@ export async function runSkillTestHarness(opts) {
1088
2136
  // --keep, which retains the harness dir for inspection but has no business
1089
2137
  // retaining the key), and never conditionally.
1090
2138
  removeVatOnlyDir(evalSuiteHoldDir);
2139
+ // Per-eval workspaces are the executor's cwd; `retainWorkspaces` (minted with
2140
+ // the root above) is the single author of whether they survive.
2141
+ if (!retainWorkspaces)
2142
+ removeVatOnlyDir(workspacesRoot);
1091
2143
  };
1092
2144
  const removeSignalCleanup = installSignalCleanup({ onSignal: cleanup });
1093
2145
  // Hoisted so the finally can surface packaging friction even when a throw
@@ -1097,6 +2149,23 @@ export async function runSkillTestHarness(opts) {
1097
2149
  // once merging succeeded; on a pre-merge throw the finally simply finds no
1098
2150
  // friction.json and emits nothing (a no-op).
1099
2151
  let frictionOut;
2152
+ /**
2153
+ * Emit the friction report AT MOST ONCE per run.
2154
+ *
2155
+ * There are now two call sites, and they exist for different reasons that must
2156
+ * not become two reports: the happy path calls it deliberately BEFORE the
2157
+ * baseline delta/banner (so ~1250 rows of friction cannot come between the two —
2158
+ * see the call site), and the `finally` calls it for the throw paths, where no
2159
+ * happy-path call ever happened. A flag rather than a "did we already return normally?"
2160
+ * test, because the throw can land anywhere between the two.
2161
+ */
2162
+ let frictionEmitted = false;
2163
+ const emitFrictionOnce = () => {
2164
+ if (frictionEmitted)
2165
+ return;
2166
+ frictionEmitted = true;
2167
+ emitFrictionReport(frictionOut);
2168
+ };
1100
2169
  try {
1101
2170
  // Step 3: Stage the harness FIRST — the subject's own evals/evals.json lands
1102
2171
  // inside its staged dir, so we must stage before we can locate it. The subject's
@@ -1104,6 +2173,19 @@ export async function runSkillTestHarness(opts) {
1104
2173
  // arrives via opts.subjectSource; build no longer happens in-domain here.
1105
2174
  const resolveCtx = buildResolveCtx(harnessRoot, repoRoot);
1106
2175
  const items = buildStageItems(opts, repoRoot);
2176
+ // Hold a copy of the subject's suite ONLY when this run would have to read it
2177
+ // from there. `resolveEvalSuitePath` prefers the AUTHORED source and falls back
2178
+ // to the hold dir, so when the authored copy exists the held one is never read
2179
+ // — and staging was writing it anyway, putting a second copy of the
2180
+ // `expected_output` answer key in the shared OS temp dir on the common path,
2181
+ // for nothing. The hold dir is 0700 and outside the executor's `--add-dir`
2182
+ // sandbox, but that is Claude's permission model, not an OS boundary: skill
2183
+ // code runs as the same uid and `ls $TMPDIR` finds it. The cheapest answer to
2184
+ // "can the executor reach the answer key" is not to write it there.
2185
+ // Same predicate as `resolveEvalSuitePath`'s rule 1, so the two cannot disagree
2186
+ // about which copy this run uses.
2187
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- authored source path resolved from opts
2188
+ const authoredSuiteExists = existsSync(resolveScaffoldEvalsPath(opts, repoRoot, evalsRef));
1107
2189
  const stageResult = await stageHarness({
1108
2190
  harnessRoot,
1109
2191
  items,
@@ -1111,7 +2193,7 @@ export async function runSkillTestHarness(opts) {
1111
2193
  ctx: resolveCtx,
1112
2194
  currentUid,
1113
2195
  evalsSubpath,
1114
- evalSuiteHoldDir,
2196
+ ...(authoredSuiteExists ? {} : { evalSuiteHoldDir }),
1115
2197
  });
1116
2198
  const { pluginDirs, subjectStagedDir, subjectPluginRoot, skippedOptional } = stageResult;
1117
2199
  emitSkippedOptionalWarning(skippedOptional);
@@ -1147,10 +2229,13 @@ export async function runSkillTestHarness(opts) {
1147
2229
  // Step 5.5: Parse the eval suite and stage per-eval input workspaces. The
1148
2230
  // parsed suite is threaded on so the eval loop has the entries without
1149
2231
  // re-reading; declaredEvalCount is derived from it (suite.evals.length).
1150
- const workspaceStageResult = attemptStageWorkspaces(evalsPath, harnessRoot);
2232
+ const workspaceStageResult = attemptStageWorkspaces(evalsPath, harnessRoot, workspacesRoot, armDirs);
1151
2233
  if ('exitCode' in workspaceStageResult)
1152
2234
  return workspaceStageResult;
1153
- const { workspacesRoot, declaredEvalCount, suite } = workspaceStageResult;
2235
+ // `workspacesRoot` is resolved above (outside the try, so cleanup can reach it
2236
+ // on a signal); staging returns it unchanged, so only the derived values are
2237
+ // destructured here.
2238
+ const { declaredEvalCount, suite } = workspaceStageResult;
1154
2239
  emitEvalLintWarnings(suite.evals, (opts.declaredExecutables ?? []).map((e) => e.name));
1155
2240
  // Step 6: Enforce the §12 security ack (must pass --i-understand-this-runs-skill-code).
1156
2241
  // Only enforced when not a dry-run and not already acknowledged.
@@ -1165,11 +2250,7 @@ export async function runSkillTestHarness(opts) {
1165
2250
  // results/ — grading.json/friction.json/baseline.json come from the merged
1166
2251
  // grader fragments below, never from the (untrusted) model.
1167
2252
  const resultsDir = safePath.joinUnderRoot(harnessRoot, 'results');
1168
- // 0700, matching the harness root. `grading.json` quotes the executor
1169
- // transcript verbatim in each expectation's evidence, so whatever the skill
1170
- // read out of its input files ends up here as text — and unlike the
1171
- // workspaces, results/ SURVIVES `--keep` by design.
1172
- mkdirSyncReal(resultsDir, { recursive: true, mode: 0o700 });
2253
+ const isDryRun = opts.dryRun === true;
1173
2254
  // Record what was actually staged & tested (the subject identity + the
1174
2255
  // staged manifest fingerprint + per-entry content hashes) so a run is
1175
2256
  // auditable: which source was resolved, and whether it was rebuilt by run.ts.
@@ -1180,17 +2261,16 @@ export async function runSkillTestHarness(opts) {
1180
2261
  rebuilt: opts.rebuilt === true,
1181
2262
  };
1182
2263
  const provenancePath = safePath.join(resultsDir, 'provenance.json');
1183
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1184
- writeFileSync(provenancePath, JSON.stringify(provenance, null, 2) + '\n', 'utf-8');
2264
+ openResultsDir({ dryRun: isDryRun, resultsDir, provenancePath, provenance });
1185
2265
  process.stderr.write(`Provenance: ${provenance.fingerprint}\n`);
1186
- // Resolve the results/ artifact paths and WIPE any prior run's artifacts up
1187
- // front: harnessRoot is deterministic and may be reused (--keep/--out, or after
1188
- // a crash before cleanup), so a stale grading/friction/baseline.json must never
1189
- // leak into — or be echoed by the finally on — this run. `frictionOut` is
1190
- // hoisted so the finally can echo THIS run's friction (and only after the wipe).
2266
+ // Resolved here, WIPED after the dry-run short-circuit below. harnessRoot is
2267
+ // deterministic and may be reused (--keep/--out, or after a crash before
2268
+ // cleanup), so a stale grading/friction/baseline.json must never leak into — or
2269
+ // be echoed by the finally on — this run; but the wipe is a REAL run's
2270
+ // prerogative, and running it here put it ahead of the dry-run return and of
2271
+ // every remaining pre-pipeline failure point (Step 7.5 token resolution among
2272
+ // them, which is how a FAILING dry run also took the prior artifacts with it).
1191
2273
  const artifacts = resolveArtifactPaths(resultsDir);
1192
- wipeStaleArtifacts(artifacts);
1193
- frictionOut = artifacts.frictionOut;
1194
2274
  // Step 7.5: Resolve the executor's declared test env (Features A + B). Token
1195
2275
  // resolution can hard-fail (exit 2) on an unknown ${token}; do it before the
1196
2276
  // dry-run short-circuit so a dry run validates interpolation too. resolvedAuth
@@ -1216,8 +2296,9 @@ export async function runSkillTestHarness(opts) {
1216
2296
  const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
1217
2297
  const modelFlag = buildModelFlag(knobs.model);
1218
2298
  process.stderr.write(`Model: ${knobs.model ?? '(claude default)'} | grader: ${graderModel}\n`);
1219
- // Step 8: Dry-run short-circuit — return assembled info without spawning.
1220
- if (opts.dryRun === true) {
2299
+ // Step 8: Dry-run short-circuit — return assembled info without spawning, and
2300
+ // without having touched results/ (see Step 7).
2301
+ if (isDryRun) {
1221
2302
  return {
1222
2303
  harnessPath: harnessRoot,
1223
2304
  exitCode: SkillTestExitCode.Ok,
@@ -1231,11 +2312,20 @@ export async function runSkillTestHarness(opts) {
1231
2312
  provenanceEntryCount: provenance.entries.length,
1232
2313
  modelFlag,
1233
2314
  evalCount: declaredEvalCount,
2315
+ // The one fact that doubles the spawn count, and the one the preview was
2316
+ // blind to — `opts.baseline` was in scope here the whole time.
2317
+ baseline: opts.baseline === true,
1234
2318
  concurrency,
1235
2319
  graderModel,
2320
+ maxBudgetUsd: knobs.maxBudgetUsd,
1236
2321
  }),
1237
2322
  };
1238
2323
  }
2324
+ // Past the dry-run return, so this is a REAL run: evict any prior run's
2325
+ // artifacts before writing our own. `frictionOut` is assigned WITH the wipe, so
2326
+ // the `finally` can only ever echo THIS run's friction.
2327
+ wipeStaleArtifacts(artifacts);
2328
+ frictionOut = artifacts.frictionOut;
1239
2329
  // Step 9: Run the vat-owned executor→grader pipeline. ONE secret per-run nonce
1240
2330
  // is stamped into every grader prompt (via stdin) and re-verified per fragment
1241
2331
  // on merge — this is what distinguishes a fragment produced by a grader WE
@@ -1258,8 +2348,15 @@ export async function runSkillTestHarness(opts) {
1258
2348
  const evalCtx = {
1259
2349
  subjectStagedDir,
1260
2350
  workspacesRoot,
2351
+ armDirs,
2352
+ harnessRoot,
1261
2353
  pluginDirs,
1262
2354
  graderOutDir,
2355
+ evalSuiteHoldDir,
2356
+ skillContentNeedles: resolveSkillContentNeedles(subjectStagedDir, suite.evals, {
2357
+ workspacesRoot,
2358
+ armDirs,
2359
+ }),
1263
2360
  runNonce,
1264
2361
  graderModel,
1265
2362
  costAccumulator,
@@ -1283,17 +2380,24 @@ export async function runSkillTestHarness(opts) {
1283
2380
  // higher (more expensive) tiers are SKIPPED (never graded, never passed), so a
1284
2381
  // broken foundational expectation stops the run from spending on hard tiers.
1285
2382
  // Each tier is one runPipeline: bounded-parallel, retrying a RateLimitSignal
1286
- // per item. An InternalHarnessError thrown by any executor/grader (timeout/
1287
- // stall/spawn-error, grader failure, missing fragment, nonce mismatch)
1288
- // propagates OUT unhandled → mapErrorToExitCode → exit 1; a spawn or grader
1289
- // break is never laundered into a pass/fail verdict (R1 no-laundering).
1290
- const { fragments, skipped } = await runEvalsTiered({
2383
+ // per item. An InternalHarnessError thrown by a TREATMENT-arm executor/grader
2384
+ // (timeout/stall/spawn-error, grader failure, missing fragment, nonce mismatch,
2385
+ // declared-count mismatch) propagates OUT unhandled → mapErrorToExitCode →
2386
+ // exit 1; a spawn or grader break is never laundered into a pass/fail verdict
2387
+ // (R1 no-laundering). The same break on the CONTROL arm is recorded and the run
2388
+ // continues — see runEvalWorker for why the two arms are not symmetric.
2389
+ const { fragments, controlFailures, skipped } = await runEvalsTiered({
1291
2390
  evals: suite.evals,
1292
2391
  baseline: opts.baseline === true,
1293
2392
  runTier: (items) => runPipeline({
1294
2393
  items,
1295
2394
  concurrency,
1296
2395
  worker: (item) => runEvalWorker(item, evalCtx),
2396
+ // The worker rethrows every RateLimitSignal so the pipeline can retry; this
2397
+ // is where an EXHAUSTED one stops being a retry and becomes an outcome. Only
2398
+ // the pipeline knows the budget is spent (see rateLimitExhaustedOutcome).
2399
+ onRetriesExhausted: (item, _index, error) => rateLimitExhaustedOutcome(item, error),
2400
+ ...(opts.rateLimitBackoffMs === undefined ? {} : { onRateLimit: opts.rateLimitBackoffMs }),
1297
2401
  }),
1298
2402
  });
1299
2403
  // Step 10: vat merges the grader fragments and writes grading.json/friction.json/
@@ -1301,11 +2405,32 @@ export async function runSkillTestHarness(opts) {
1301
2405
  // authoritative prose-expectation verdict from the WITH-arm per-expectation
1302
2406
  // `passed` flags — NOT any self-reported summary. An executor CLEAN failure
1303
2407
  // reaches here as a FAILing fragment → composite verdict → exit 4.
1304
- const { verdict, toolEval } = writeRunArtifactsAndReconcile(fragments, runNonce, artifacts);
2408
+ const { verdict, toolEval, baselineReport } = writeRunArtifactsAndReconcile({
2409
+ fragments,
2410
+ runNonce,
2411
+ paths: artifacts,
2412
+ signals: contaminationSignalsFor(evalCtx),
2413
+ controlFailures,
2414
+ // `skipped` was in scope here and simply not passed, which is how the delta
2415
+ // block and its printed line came to describe a truncated run as a complete one.
2416
+ skipped,
2417
+ });
1305
2418
  // D2 fail-closed gate: vat is the SOLE writer of results/, so a missing/unparseable/
1306
- // invalid grading.json, friction.json, or tool-eval.json after the merge is a HARNESS
1307
- // bug (exit 1), never a skill fault. A SEPARATE explicit check, not a prompt invariant.
1308
- assertVatWroteArtifacts(artifacts);
2419
+ // invalid grading.json, friction.json, tool-eval.json — or, on a baseline run,
2420
+ // baseline.json — after the merge is a HARNESS bug (exit 1), never a skill fault.
2421
+ // A SEPARATE explicit check, not a prompt invariant.
2422
+ assertVatWroteArtifacts(artifacts, opts.baseline === true);
2423
+ // Friction FIRST, then the baseline report — the ORDER is the point. Friction is
2424
+ // capped at 50 lines of 2000 characters, so emitting it after the ⚠️ contaminated
2425
+ // banner (which is what the `finally` used to do) could put ~1250 terminal rows
2426
+ // between the operator and the only line saying the delta above is meaningless.
2427
+ // Emitting it here leaves nothing of vat's own between the delta and its caveat;
2428
+ // `emitFrictionOnce` keeps the `finally` call a no-op so a normal run reports it
2429
+ // exactly once. The CLI still appends `Harness:`/`Results:`/`Workspaces:`/
2430
+ // `Summary:` after the harness returns, so the banner is last-but-2-to-4, not
2431
+ // last — a bounded tail, unlike the friction report.
2432
+ emitFrictionOnce();
2433
+ emitBaselineReport(baselineReport);
1309
2434
  // Composite verdict: the run passes only when BOTH output expectations AND every
1310
2435
  // tool-expectation verdict passed — so an output all-pass where a `mustRun` never
1311
2436
  // ran yields FAIL → exit 4. Tool verdicts stay in tool-eval.json (C2); the composite
@@ -1316,6 +2441,12 @@ export async function runSkillTestHarness(opts) {
1316
2441
  const summary = buildRunSummaryWithSkips(verdict, toolEval, allPassed, skipped, costAccumulator);
1317
2442
  return {
1318
2443
  harnessPath: harnessRoot,
2444
+ // Reported ONLY when the workspaces outlive the run. The `finally` below
2445
+ // runs cleanup BEFORE this value ever reaches a caller, so reporting it
2446
+ // unconditionally handed the operator a path to a directory that no longer
2447
+ // existed. Same predicate as cleanup's, by construction.
2448
+ ...(retainWorkspaces ? { workspacesPath: workspacesRoot } : {}),
2449
+ resultsPath: resultsDir,
1319
2450
  exitCode: verdictExitCode(allPassed, opts.tolerateEvalFailure === true),
1320
2451
  summary,
1321
2452
  };
@@ -1324,8 +2455,10 @@ export async function runSkillTestHarness(opts) {
1324
2455
  // Surface any packaging-fidelity friction VAT merged into friction.json to
1325
2456
  // STDERR BEFORE cleanup removes the harness dir. Best-effort + stderr-only
1326
2457
  // (stdout stays machine-readable); a no-op when the path is unassigned (a
1327
- // pre-merge throw) or friction.json is absent or empty.
1328
- emitFrictionReport(frictionOut);
2458
+ // pre-merge throw) or friction.json is absent or empty. On a run that reached
2459
+ // the verdict this is ALREADY a no-op — the happy path emits it earlier, ahead
2460
+ // of the baseline banner — so what is left here is the throw paths.
2461
+ emitFrictionOnce();
1329
2462
  // Remove the signal handlers first (no listener leak across runs), then run
1330
2463
  // the same cleanup: release the lock, remove the harness dir, and remove the
1331
2464
  // vat-only grader dir (which lives outside harnessRoot).