@vibe-agent-toolkit/agent-skills 0.2.0-rc.7 → 0.2.0-rc.8

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 (208) hide show
  1. package/README.md +5 -5
  2. package/dist/builder.d.ts.map +1 -1
  3. package/dist/builder.js +7 -17
  4. package/dist/builder.js.map +1 -1
  5. package/dist/files-config.d.ts +2 -2
  6. package/dist/files-config.d.ts.map +1 -1
  7. package/dist/files-config.js +1 -7
  8. package/dist/files-config.js.map +1 -1
  9. package/dist/import.d.ts.map +1 -1
  10. package/dist/import.js +0 -4
  11. package/dist/import.js.map +1 -1
  12. package/dist/inventory/types.d.ts +8 -0
  13. package/dist/inventory/types.d.ts.map +1 -1
  14. package/dist/inventory/types.js.map +1 -1
  15. package/dist/plugin-distribution-layout.d.ts.map +1 -1
  16. package/dist/plugin-distribution-layout.js +0 -2
  17. package/dist/plugin-distribution-layout.js.map +1 -1
  18. package/dist/post-build-checks.d.ts.map +1 -1
  19. package/dist/post-build-checks.js +12 -11
  20. package/dist/post-build-checks.js.map +1 -1
  21. package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
  22. package/dist/schemas/installed-plugins-registry.js +7 -1
  23. package/dist/schemas/installed-plugins-registry.js.map +1 -1
  24. package/dist/skill-identity.d.ts +8 -3
  25. package/dist/skill-identity.d.ts.map +1 -1
  26. package/dist/skill-identity.js +13 -6
  27. package/dist/skill-identity.js.map +1 -1
  28. package/dist/skill-packager.d.ts +2 -1
  29. package/dist/skill-packager.d.ts.map +1 -1
  30. package/dist/skill-packager.js +47 -40
  31. package/dist/skill-packager.js.map +1 -1
  32. package/dist/skill-source/content-hash.d.ts.map +1 -1
  33. package/dist/skill-source/content-hash.js +21 -10
  34. package/dist/skill-source/content-hash.js.map +1 -1
  35. package/dist/skill-source/fetch-cache.d.ts.map +1 -1
  36. package/dist/skill-source/fetch-cache.js +0 -5
  37. package/dist/skill-source/fetch-cache.js.map +1 -1
  38. package/dist/skill-source/git-clone.d.ts.map +1 -1
  39. package/dist/skill-source/git-clone.js +0 -2
  40. package/dist/skill-source/git-clone.js.map +1 -1
  41. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  42. package/dist/skill-source/sources/url-source.js +0 -1
  43. package/dist/skill-source/sources/url-source.js.map +1 -1
  44. package/dist/skill-source/stage.d.ts.map +1 -1
  45. package/dist/skill-source/stage.js +0 -6
  46. package/dist/skill-source/stage.js.map +1 -1
  47. package/dist/skill-test/baseline-integrity.d.ts.map +1 -1
  48. package/dist/skill-test/baseline-integrity.js +8 -2
  49. package/dist/skill-test/baseline-integrity.js.map +1 -1
  50. package/dist/skill-test/build-hook.d.ts +3 -2
  51. package/dist/skill-test/build-hook.d.ts.map +1 -1
  52. package/dist/skill-test/build-hook.js +4 -4
  53. package/dist/skill-test/build-hook.js.map +1 -1
  54. package/dist/skill-test/declared-env.d.ts +5 -4
  55. package/dist/skill-test/declared-env.d.ts.map +1 -1
  56. package/dist/skill-test/declared-env.js +7 -9
  57. package/dist/skill-test/declared-env.js.map +1 -1
  58. package/dist/skill-test/eval-executor.d.ts +1 -1
  59. package/dist/skill-test/eval-executor.js +2 -2
  60. package/dist/skill-test/eval-executor.js.map +1 -1
  61. package/dist/skill-test/eval-fragment.d.ts +7 -5
  62. package/dist/skill-test/eval-fragment.d.ts.map +1 -1
  63. package/dist/skill-test/eval-fragment.js +13 -7
  64. package/dist/skill-test/eval-fragment.js.map +1 -1
  65. package/dist/skill-test/eval-grader.d.ts +56 -4
  66. package/dist/skill-test/eval-grader.d.ts.map +1 -1
  67. package/dist/skill-test/eval-grader.js +138 -73
  68. package/dist/skill-test/eval-grader.js.map +1 -1
  69. package/dist/skill-test/eval-inputs.d.ts +8 -7
  70. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  71. package/dist/skill-test/eval-inputs.js +9 -11
  72. package/dist/skill-test/eval-inputs.js.map +1 -1
  73. package/dist/skill-test/eval-lint.d.ts +4 -4
  74. package/dist/skill-test/eval-lint.js +5 -5
  75. package/dist/skill-test/eval-lint.js.map +1 -1
  76. package/dist/skill-test/eval-suite-isolation.d.ts.map +1 -1
  77. package/dist/skill-test/eval-suite-isolation.js +8 -5
  78. package/dist/skill-test/eval-suite-isolation.js.map +1 -1
  79. package/dist/skill-test/evals-template.d.ts +1 -1
  80. package/dist/skill-test/evals-template.d.ts.map +1 -1
  81. package/dist/skill-test/evals-template.js +1 -3
  82. package/dist/skill-test/evals-template.js.map +1 -1
  83. package/dist/skill-test/executor-prompt.d.ts +2 -2
  84. package/dist/skill-test/executor-prompt.js +2 -2
  85. package/dist/skill-test/failure-reason.d.ts +113 -0
  86. package/dist/skill-test/failure-reason.d.ts.map +1 -0
  87. package/dist/skill-test/failure-reason.js +124 -0
  88. package/dist/skill-test/failure-reason.js.map +1 -0
  89. package/dist/skill-test/fragment-merge.d.ts +2 -2
  90. package/dist/skill-test/fragment-merge.js +4 -4
  91. package/dist/skill-test/grader-prompt.d.ts +2 -2
  92. package/dist/skill-test/grader-prompt.d.ts.map +1 -1
  93. package/dist/skill-test/grader-prompt.js +12 -8
  94. package/dist/skill-test/grader-prompt.js.map +1 -1
  95. package/dist/skill-test/grading-adapter.d.ts +7 -3
  96. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  97. package/dist/skill-test/grading-adapter.js +10 -9
  98. package/dist/skill-test/grading-adapter.js.map +1 -1
  99. package/dist/skill-test/harness-location.d.ts +3 -2
  100. package/dist/skill-test/harness-location.d.ts.map +1 -1
  101. package/dist/skill-test/harness-location.js +5 -16
  102. package/dist/skill-test/harness-location.js.map +1 -1
  103. package/dist/skill-test/index.d.ts +1 -1
  104. package/dist/skill-test/index.d.ts.map +1 -1
  105. package/dist/skill-test/index.js +1 -1
  106. package/dist/skill-test/index.js.map +1 -1
  107. package/dist/skill-test/lock.d.ts +11 -10
  108. package/dist/skill-test/lock.d.ts.map +1 -1
  109. package/dist/skill-test/lock.js +13 -14
  110. package/dist/skill-test/lock.js.map +1 -1
  111. package/dist/skill-test/pipeline.d.ts +3 -1
  112. package/dist/skill-test/pipeline.d.ts.map +1 -1
  113. package/dist/skill-test/pipeline.js +5 -1
  114. package/dist/skill-test/pipeline.js.map +1 -1
  115. package/dist/skill-test/preflight.d.ts.map +1 -1
  116. package/dist/skill-test/preflight.js +0 -1
  117. package/dist/skill-test/preflight.js.map +1 -1
  118. package/dist/skill-test/prompt-invariants.d.ts +4 -2
  119. package/dist/skill-test/prompt-invariants.d.ts.map +1 -1
  120. package/dist/skill-test/prompt-invariants.js +5 -4
  121. package/dist/skill-test/prompt-invariants.js.map +1 -1
  122. package/dist/skill-test/run-harness.d.ts +160 -123
  123. package/dist/skill-test/run-harness.d.ts.map +1 -1
  124. package/dist/skill-test/run-harness.js +309 -173
  125. package/dist/skill-test/run-harness.js.map +1 -1
  126. package/dist/skill-test/staging.d.ts +12 -6
  127. package/dist/skill-test/staging.d.ts.map +1 -1
  128. package/dist/skill-test/staging.js +15 -12
  129. package/dist/skill-test/staging.js.map +1 -1
  130. package/dist/skill-test/tier-plan.d.ts +1 -1
  131. package/dist/skill-test/tier-plan.js +1 -1
  132. package/dist/skill-test/tool-eval-schema.js +2 -2
  133. package/dist/skill-test/vendor-manifest.d.ts +2 -1
  134. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  135. package/dist/skill-test/vendor-manifest.js +10 -10
  136. package/dist/skill-test/vendor-manifest.js.map +1 -1
  137. package/dist/test-input.d.ts +2 -2
  138. package/dist/test-input.d.ts.map +1 -1
  139. package/dist/test-input.js +4 -5
  140. package/dist/test-input.js.map +1 -1
  141. package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
  142. package/dist/validators/agent-instruction-presence.js +0 -1
  143. package/dist/validators/agent-instruction-presence.js.map +1 -1
  144. package/dist/validators/bundled-resource-link-detection.d.ts +5 -3
  145. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  146. package/dist/validators/bundled-resource-link-detection.js +38 -38
  147. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  148. package/dist/validators/format-detection.d.ts +3 -0
  149. package/dist/validators/format-detection.d.ts.map +1 -1
  150. package/dist/validators/format-detection.js +41 -26
  151. package/dist/validators/format-detection.js.map +1 -1
  152. package/dist/validators/frontmatter-validation.js +1 -1
  153. package/dist/validators/frontmatter-validation.js.map +1 -1
  154. package/dist/validators/marketplace-validator.d.ts.map +1 -1
  155. package/dist/validators/marketplace-validator.js +0 -1
  156. package/dist/validators/marketplace-validator.js.map +1 -1
  157. package/dist/validators/mcp-tool-qualification.d.ts +1 -1
  158. package/dist/validators/mcp-tool-qualification.js +1 -1
  159. package/dist/validators/packaged-size-limit.d.ts.map +1 -1
  160. package/dist/validators/packaged-size-limit.js +0 -2
  161. package/dist/validators/packaged-size-limit.js.map +1 -1
  162. package/dist/validators/packaging-validator.d.ts +2 -2
  163. package/dist/validators/packaging-validator.d.ts.map +1 -1
  164. package/dist/validators/packaging-validator.js +8 -11
  165. package/dist/validators/packaging-validator.js.map +1 -1
  166. package/dist/validators/plugin-hosted-shape.d.ts +3 -1
  167. package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
  168. package/dist/validators/plugin-hosted-shape.js +5 -6
  169. package/dist/validators/plugin-hosted-shape.js.map +1 -1
  170. package/dist/validators/referenced-path-missing.d.ts.map +1 -1
  171. package/dist/validators/referenced-path-missing.js +0 -1
  172. package/dist/validators/referenced-path-missing.js.map +1 -1
  173. package/dist/validators/registry-validator.d.ts.map +1 -1
  174. package/dist/validators/registry-validator.js +0 -1
  175. package/dist/validators/registry-validator.js.map +1 -1
  176. package/dist/validators/rule-engine/index.d.ts +1 -1
  177. package/dist/validators/rule-engine/rule-context.d.ts +3 -3
  178. package/dist/validators/rule-engine/rule-context.js +1 -1
  179. package/dist/validators/rule-engine/rule-engine.d.ts +1 -1
  180. package/dist/validators/rule-engine/rule-engine.js +5 -5
  181. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  182. package/dist/validators/skill-validator.d.ts.map +1 -1
  183. package/dist/validators/skill-validator.js +41 -51
  184. package/dist/validators/skill-validator.js.map +1 -1
  185. package/dist/validators/source-only-codes.js +1 -1
  186. package/dist/validators/source-only-codes.js.map +1 -1
  187. package/dist/validators/types.d.ts +7 -1
  188. package/dist/validators/types.d.ts.map +1 -1
  189. package/dist/validators/unified-validator.d.ts.map +1 -1
  190. package/dist/validators/unified-validator.js +3 -4
  191. package/dist/validators/unified-validator.js.map +1 -1
  192. package/dist/validators/validation-rules.d.ts +3 -3
  193. package/dist/validators/validation-rules.d.ts.map +1 -1
  194. package/dist/validators/validation-rules.js +9 -38
  195. package/dist/validators/validation-rules.js.map +1 -1
  196. package/dist/validators/walker-to-issues.d.ts +2 -0
  197. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  198. package/dist/validators/walker-to-issues.js +17 -12
  199. package/dist/validators/walker-to-issues.js.map +1 -1
  200. package/dist/walk-link-graph.d.ts +2 -0
  201. package/dist/walk-link-graph.d.ts.map +1 -1
  202. package/dist/walk-link-graph.js +20 -8
  203. package/dist/walk-link-graph.js.map +1 -1
  204. package/package.json +11 -19
  205. package/dist/skill-test/exit-codes.d.ts +0 -134
  206. package/dist/skill-test/exit-codes.d.ts.map +0 -1
  207. package/dist/skill-test/exit-codes.js +0 -224
  208. package/dist/skill-test/exit-codes.js.map +0 -1
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Implements the full harness sequence as a pure async function (no process.exit):
5
5
  * lock → assert safe workdir + harness root → stage → resolve staged-subject
6
- * eval path → bootstrap-check (scaffold template + exit 3 if absent)
6
+ * eval path → bootstrap-check (scaffold template + exit 2 with `Reason: bootstrap` if absent)
7
7
  * → preflight (return early exitCode 2 on failure) → ack enforcement
8
8
  * → dry-run short-circuit → build per-eval work items → run the vat-owned
9
9
  * executor→grader pipeline (bounded-parallel) → merge grader fragments →
@@ -15,7 +15,8 @@ import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, rmSync, w
15
15
  import { homedir } from 'node:os';
16
16
  import { basename, dirname } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
- import { mkdirSyncReal, normalizedTmpdir, resolveAssetReference, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
18
+ import { ExitCode } from '@vibe-agent-toolkit/schema';
19
+ import { direntKind, isPathAbsentError, mkdirSyncReal, normalizedTmpdir, prefixMessageOnce, resolveAssetReference, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
19
20
  import { getToolVersion, safeExecResult, } from '@vibe-agent-toolkit/utils/process';
20
21
  import { killAllActiveClaudeChildren, probeAuthStatus, } from '@vibe-agent-toolkit/utils/skill-test';
21
22
  import { resolveSkillSource } from '../skill-source/resolve-skill-source.js';
@@ -23,12 +24,12 @@ import { BaselineArtifactSchema, computeBaselineDelta, formatBaselineDeltaLine,
23
24
  import { activeContaminationSignals, armExpectationSkew, detectBaselineContamination, scrubControlArmEnv, skillContentNeedles, summarizeBaselineIntegrity, } from './baseline-integrity.js';
24
25
  import { assembleChildEnv, assertKnownEnvTokens, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
25
26
  import { runExecutorForEval } from './eval-executor.js';
26
- import { runGraderForEval } from './eval-grader.js';
27
+ import { GRADER_FRAGMENT_UNPARSEABLE, GraderFragmentUnparseableError, runGraderForEval } from './eval-grader.js';
27
28
  import { armDirSegment, EvalInputError, parseEvalSuite, stageEvalWorkspaces, } from './eval-inputs.js';
28
29
  import { lintEvalExpectations, lintToolExpectationExecutables } from './eval-lint.js';
29
30
  import { DEFAULT_EVALS_SUBPATH } from './eval-suite-isolation.js';
30
31
  import { writeEvalsTemplate } from './evals-template.js';
31
- import { BootstrapNeededError, DuplicateStagedSkillError, InternalHarnessError, SkillTestExitCode, } from './exit-codes.js';
32
+ import { BootstrapNeededError, DuplicateStagedSkillError, InternalHarnessError, } from './failure-reason.js';
32
33
  import { mergeFragmentsToFriction, mergeFragmentsToGrading, mergeFragmentsToToolEval } from './fragment-merge.js';
33
34
  import { FrictionReportSchema } from './friction-schema.js';
34
35
  import { DEFAULT_CONCURRENCY, DEFAULT_GRADER_MODEL } from './grader-model.js';
@@ -55,7 +56,7 @@ import { verifyVendoredManifest } from './vendor-manifest.js';
55
56
  const VENDORED_SKILL_CREATOR_DIR = safePath.resolve(dirname(fileURLToPath(import.meta.url)), '../../vendor/skill-creator');
56
57
  /**
57
58
  * Absolute path to the vendored skill-creator grader rubric each per-eval grader
58
- * spawn is told to judge against (issue #145). Lives inside
59
+ * spawn is told to judge against. Lives inside
59
60
  * {@link VENDORED_SKILL_CREATOR_DIR}, whose hash manifest is verified during
60
61
  * preflight — so the rubric the grader uses is the pinned, integrity-checked copy.
61
62
  */
@@ -88,28 +89,30 @@ export const SKILL_TEST_BUILTIN_CAPS = {
88
89
  };
89
90
  /**
90
91
  * Resolve the effective PER-EVAL wall-clock timeout (ms). Each executor and
91
- * grader spawn is an independent, bounded-parallel unit (issue #145), so the
92
+ * grader spawn is an independent, bounded-parallel unit, so the
92
93
  * budget is a flat per-spawn ceiling — NOT scaled by the suite size the way the
93
94
  * old single serial run's budget was. An explicit `--timeout` (seconds)
94
95
  * wins; otherwise the flat {@link DEFAULT_TIMEOUT_MS} default applies.
95
96
  */
96
- export function resolveTimeoutMs(opts) {
97
+ function resolveTimeoutMs(opts) {
97
98
  return opts.timeout === undefined ? DEFAULT_TIMEOUT_MS : opts.timeout * 1000;
98
99
  }
99
100
  /**
100
101
  * Map an eval verdict to a process exit code. Default behavior (fail-closed): a
101
- * failing verdict escalates to EvalFailure (4) — distinct from the harness-broke
102
- * codes (1/2/3) so a CI consumer can `case $? in 0);; 4) tolerate;; *) hard fail;; esac`.
102
+ * failing verdict is `FINDINGS` — the harness ran to completion and what it
103
+ * examined did not pass, exactly as `vat skills validate` reports an
104
+ * error-severity finding — while a harness that could not run is `ERROR`, so a
105
+ * CI consumer can `case $? in 0);; 1) tolerate;; *) hard fail;; esac`.
103
106
  * When `tolerateEvalFailure` is set (interactive opt-out), a failing verdict is
104
- * downgraded to Ok (0) and the count lives only in the summary/grading.json.
107
+ * downgraded to `OK` and the count lives only in the summary/grading.json.
105
108
  */
106
109
  export function verdictExitCode(allPassed, tolerateEvalFailure) {
107
- return !allPassed && !tolerateEvalFailure ? SkillTestExitCode.EvalFailure : SkillTestExitCode.Ok;
110
+ return !allPassed && !tolerateEvalFailure ? ExitCode.FINDINGS : ExitCode.OK;
108
111
  }
109
- export function resolveStallMs(opts) {
112
+ function resolveStallMs(opts) {
110
113
  return opts.stall === undefined ? undefined : opts.stall * 1000;
111
114
  }
112
- export function resolveKnobs(opts) {
115
+ function resolveKnobs(opts) {
113
116
  const result = {
114
117
  maxTurns: opts.maxTurns ?? DEFAULT_MAX_TURNS,
115
118
  maxBudgetUsd: opts.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
@@ -129,13 +132,13 @@ export function resolveKnobs(opts) {
129
132
  * sources (npm/url/vendored/workspace) have no local source tree to walk, so they
130
133
  * are always staged flat (returns undefined). undefined → flat staging.
131
134
  */
132
- export function detectItemPluginLayout(source, repoRoot) {
135
+ function detectItemPluginLayout(source, repoRoot) {
133
136
  if (!('path' in source))
134
137
  return undefined;
135
138
  const sourceDir = resolveAssetReference(source.path, repoRoot);
136
139
  return detectPluginLayout(sourceDir, existsSync) ?? undefined;
137
140
  }
138
- export function makeStageItem(name, source, repoRoot, role, optional) {
141
+ function makeStageItem(name, source, repoRoot, role, optional) {
139
142
  const pluginLayout = detectItemPluginLayout(source, repoRoot);
140
143
  return {
141
144
  name,
@@ -149,14 +152,14 @@ export function makeStageItem(name, source, repoRoot, role, optional) {
149
152
  * Build the full set of items `stageHarness` will stage: the subject, every
150
153
  * REQUIRED `--with` companion, and every OPTIONAL `--with-optional` companion.
151
154
  * Both `with` and `optional` STAGE the named companion and make it invocable —
152
- * they differ only in required-vs-optional resolution (issue #153; a `--with`
153
- * name that named no positional skill used to be silently dropped, never staged,
154
- * no manifest trace). Fail-closed on a DUPLICATE staged name (subject / `with` /
155
+ * they differ only in required-vs-optional resolution (a `--with` name that
156
+ * names no positional skill must never be silently dropped — unstaged, with no
157
+ * manifest trace). Fail-closed on a DUPLICATE staged name (subject / `with` /
155
158
  * `optional` all share one namespace): the first repeat throws
156
159
  * {@link DuplicateStagedSkillError} rather than silently letting a later item
157
160
  * clobber an earlier one under the same staged slot.
158
161
  */
159
- export function buildStageItems(opts, repoRoot) {
162
+ function buildStageItems(opts, repoRoot) {
160
163
  const items = [];
161
164
  const seen = new Set();
162
165
  const pushItem = (item) => {
@@ -179,7 +182,7 @@ export function buildStageItems(opts, repoRoot) {
179
182
  }
180
183
  return items;
181
184
  }
182
- export function buildResolveCtx(harnessRoot, repoRoot) {
185
+ function buildResolveCtx(harnessRoot, repoRoot) {
183
186
  return {
184
187
  repoRoot,
185
188
  stagingRoot: safePath.joinUnderRoot(harnessRoot, 'staged'),
@@ -194,7 +197,7 @@ export function buildResolveCtx(harnessRoot, repoRoot) {
194
197
  * of each other (`--max-turns` / `--max-turns-per-eval` would collide). Neither
195
198
  * side of the token may be a letter or a dash.
196
199
  */
197
- export function helpTextDeclaresFlag(helpText, flag) {
200
+ function helpTextDeclaresFlag(helpText, flag) {
198
201
  // A scan rather than a built regex: the flag is data (it comes from a list a
199
202
  // future edit will extend), and building a pattern from data is both a lint
200
203
  // error here and the kind of thing that silently changes meaning when someone
@@ -211,7 +214,7 @@ export function helpTextDeclaresFlag(helpText, flag) {
211
214
  * as a NEGATIVE CONTROL: if the probe reports this as supported, the probe is
212
215
  * broken and its answer about every other flag is worthless.
213
216
  */
214
- export const FLAG_PROBE_SENTINEL = '--vat-probe-flag-that-cannot-exist';
217
+ const FLAG_PROBE_SENTINEL = '--vat-probe-flag-that-cannot-exist';
215
218
  /**
216
219
  * Build a token-free flag-support probe from `claude --help`.
217
220
  *
@@ -232,12 +235,25 @@ export const FLAG_PROBE_SENTINEL = '--vat-probe-flag-that-cannot-exist';
232
235
  * outright — the probe reports EVERY flag unsupported rather than every flag
233
236
  * supported. A probe that cannot tell must fail closed and be seen; the defect it
234
237
  * replaces failed open and was invisible.
235
- */
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);
238
+ *
239
+ * `--help` runs on the FIRST flag query, not when the probe is built. Building
240
+ * it is part of assembling preflight's input, and a preflight that never asks
241
+ * (a stubbed one, or one that fails an earlier check) must not have already
242
+ * paid for a subprocess: with the spawn at build time every harness run cost
243
+ * one real `claude --help` — 75 ms where the CLI is installed, an instant
244
+ * ENOENT where it is not — which read as a 10× platform split in a test that
245
+ * had injected a fake spawn and stubbed preflight precisely so no process
246
+ * would start.
247
+ */
248
+ function buildFlagParseProbe(runHelp = defaultClaudeHelp) {
249
+ let helpText;
250
+ return (flag) => {
251
+ if (helpText === undefined)
252
+ helpText = runHelp();
253
+ // No help output, or a sentinel "match" ⇒ the probe is not trustworthy.
254
+ const usable = helpText !== null && !helpTextDeclaresFlag(helpText, FLAG_PROBE_SENTINEL);
255
+ return usable && helpTextDeclaresFlag(helpText ?? '', flag);
256
+ };
241
257
  }
242
258
  /** Run `claude --help` once, returning its combined output (null if unreachable). */
243
259
  function defaultClaudeHelp() {
@@ -272,7 +288,7 @@ function defaultClaudeHelp() {
272
288
  * reachable: a reachable estimate that says "1 evals × 2 config" is worse than a
273
289
  * silent one, because it is quotable.
274
290
  */
275
- export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
291
+ function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
276
292
  const costEstimate = {
277
293
  evalCount: 1,
278
294
  // `configurations` IS the A/B dimension: `--baseline` runs every eval under two
@@ -304,7 +320,7 @@ export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
304
320
  preflightOpts.requireAuth = opts.requireAuth;
305
321
  return preflightOpts;
306
322
  }
307
- export function renderPreflightSummary(checks) {
323
+ function renderPreflightSummary(checks) {
308
324
  const failed = checks.filter(c => !c.passed);
309
325
  return failed.length > 0
310
326
  ? failed.map(c => ` [FAIL] ${c.name}: ${c.message}`).join('\n')
@@ -349,27 +365,34 @@ export function formatFrictionReport(items) {
349
365
  /**
350
366
  * Read the run's friction.json (if present + valid) and echo a concise report to
351
367
  * STDERR so users don't miss packaging-fidelity friction VAT merged from the grader
352
- * fragments (it is otherwise only written to disk). Best-effort: a missing, unparseable, or
353
- * empty report emits nothing. Never touches stdout (which stays machine-readable).
354
- * Accepts `undefined` (a no-op) so the harness `finally` can call it unconditionally
355
- * even when a throw preempted assignment of the friction path.
368
+ * fragments (it is otherwise only written to disk). Best-effort: a missing or empty
369
+ * report emits nothing. A report that is there but cannot be read or does not parse
370
+ * emits a one-line warning instead — this runs from the harness `finally`, so it
371
+ * must not throw, but vat is the sole writer of friction.json and a file it wrote
372
+ * that it cannot read back is worth a line. Never touches stdout (which stays
373
+ * machine-readable). Accepts `undefined` (a no-op) so the harness `finally` can
374
+ * call it unconditionally even when a throw preempted assignment of the path.
356
375
  */
357
376
  function emitFrictionReport(frictionPath) {
358
377
  if (frictionPath === undefined)
359
378
  return;
360
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
361
379
  if (!existsSync(frictionPath))
362
380
  return;
363
381
  let raw;
364
382
  try {
365
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
366
383
  raw = JSON.parse(readFileSync(frictionPath, 'utf-8'));
367
384
  }
368
- catch {
385
+ catch (err) {
386
+ process.stderr.write(`warning: could not read the friction report at ${frictionPath}: ` +
387
+ `${err instanceof Error ? err.message : String(err)}\n`);
369
388
  return;
370
389
  }
371
390
  const parsed = FrictionReportSchema.safeParse(raw);
372
- if (!parsed.success || parsed.data.items.length === 0)
391
+ if (!parsed.success) {
392
+ process.stderr.write(`warning: the friction report at ${frictionPath} does not match its schema: ${parsed.error.message}\n`);
393
+ return;
394
+ }
395
+ if (parsed.data.items.length === 0)
373
396
  return;
374
397
  process.stderr.write(`\nPackaging friction (${parsed.data.items.length}):\n`);
375
398
  process.stderr.write(formatFrictionReport(parsed.data.items) + '\n');
@@ -395,7 +418,8 @@ function emitBaselineReport(report) {
395
418
  function emitSkippedOptionalWarning(skippedOptional) {
396
419
  if (skippedOptional.length === 0)
397
420
  return;
398
- process.stderr.write(`warning: optional companion skill(s) not staged (source unresolvable): ${skippedOptional.join(', ')}\n`);
421
+ const named = skippedOptional.map((item) => `${item.name} (${item.reason})`).join(', ');
422
+ process.stderr.write(`warning: optional companion skill(s) not staged: ${named}\n`);
399
423
  }
400
424
  /**
401
425
  * Resolve the PERSISTENT location where a bootstrap scaffold should be written
@@ -404,7 +428,7 @@ function emitSkippedOptionalWarning(skippedOptional) {
404
428
  * that real source dir; otherwise we anchor under the repo root by skill name.
405
429
  */
406
430
  /** The subject skill's display name (trailing segment of the subject arg). */
407
- export function subjectSkillName(opts) {
431
+ function subjectSkillName(opts) {
408
432
  return basename(toForwardSlash(opts.subject)) || opts.subject;
409
433
  }
410
434
  /**
@@ -429,7 +453,7 @@ export function subjectSkillName(opts) {
429
453
  * into `<skillDir>/<path>`, which does not exist — so the run did not fail, it
430
454
  * bootstrapped a starter template at the bogus location and graded that.
431
455
  */
432
- export function resolveScaffoldEvalsPath(opts, repoRoot, evalsRef) {
456
+ function resolveScaffoldEvalsPath(opts, repoRoot, evalsRef) {
433
457
  const resolveFrom = (baseDir) => evalsRef === undefined
434
458
  ? safePath.join(baseDir, DEFAULT_EVALS_SUBPATH)
435
459
  : resolveAssetReference(evalsRef, baseDir);
@@ -448,7 +472,7 @@ export function resolveScaffoldEvalsPath(opts, repoRoot, evalsRef) {
448
472
  return resolveFrom(sourceDir);
449
473
  }
450
474
  /**
451
- * Bootstrap (exit 3) — called when the eval suite is absent everywhere. A real run
475
+ * Bootstrap (exit 2, `Reason: bootstrap`) — called when the eval suite is absent everywhere. A real run
452
476
  * writes a persistent template at the source scaffold location and reports it; a dry
453
477
  * run must never touch the filesystem, so it reports where a real run *would*
454
478
  * scaffold and writes nothing. Both surface the same exit-3 BootstrapNeededError.
@@ -469,7 +493,7 @@ function bootstrapEvalSuite(opts, repoRoot, evalsRef) {
469
493
  * is performing. Created 0700 and removed in the run's cleanup, exactly like
470
494
  * {@link resolveGraderOutDir}. Pure (derives a path only).
471
495
  */
472
- export function resolveEvalSuiteHoldDir(dirToken) {
496
+ function resolveEvalSuiteHoldDir(dirToken) {
473
497
  return safePath.join(normalizedTmpdir(), `vat-skill-evals-${dirToken}`);
474
498
  }
475
499
  /**
@@ -482,14 +506,13 @@ export function resolveEvalSuiteHoldDir(dirToken) {
482
506
  * 2. **The vat-only hold dir**, when staging harvested a suite that exists nowhere
483
507
  * else (a fetched artifact). Its layout mirrors an authored evals dir, so
484
508
  * `fixtures/` resolve relative to it unchanged.
485
- * 3. Neither → `undefined`, and the caller bootstraps a template (exit 3).
509
+ * 3. Neither → `undefined`, and the caller bootstraps a template (exit 2, `Reason: bootstrap`).
486
510
  *
487
511
  * Returns the suite's absolute path; its `dirname` is the base for each eval's
488
512
  * declared input `files`.
489
513
  */
490
- export function resolveEvalSuitePath(input) {
514
+ function resolveEvalSuitePath(input) {
491
515
  const authored = resolveScaffoldEvalsPath(input.opts, input.repoRoot, input.evalsRef);
492
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- authored source path resolved from opts
493
516
  if (existsSync(authored))
494
517
  return authored;
495
518
  if (!input.subjectEvalSuiteHeld)
@@ -510,7 +533,7 @@ export function resolveEvalSuitePath(input) {
510
533
  * Named by an unpredictable token, like the other vat-only dirs, and removed by
511
534
  * the same cleanup. Pure (derives a path only).
512
535
  */
513
- export function resolveWorkspacesRoot(dirToken) {
536
+ function resolveWorkspacesRoot(dirToken) {
514
537
  return safePath.join(normalizedTmpdir(), `vat-skill-test-ws-${dirToken}`);
515
538
  }
516
539
  /** Parse the staged eval suite and materialize each eval's input `files` into
@@ -530,11 +553,11 @@ export function resolveWorkspacesRoot(dirToken) {
530
553
  * 8 bytes is ample for "not guessable within a run that lasts minutes"; these are
531
554
  * blinding/anti-enumeration tokens, not secrets protecting data at rest.
532
555
  */
533
- export function mintArmWorkspaceDirs(baseline) {
556
+ function mintArmWorkspaceDirs(baseline) {
534
557
  const token = () => randomBytes(8).toString('hex');
535
558
  return baseline ? { with: token(), without: token() } : { with: token() };
536
559
  }
537
- export function stageWorkspacesForRun(evalsPath, workspacesRoot, armDirs) {
560
+ function stageWorkspacesForRun(evalsPath, workspacesRoot, armDirs) {
538
561
  rmSync(workspacesRoot, { recursive: true, force: true });
539
562
  // 0700, matching the harness root. These dirs hold each eval's declared input
540
563
  // files, which with an out-of-tree suite may be data that was never in the repo
@@ -547,7 +570,6 @@ export function stageWorkspacesForRun(evalsPath, workspacesRoot, armDirs) {
547
570
  // directory and nothing ever re-checks. Cheap: the leaf sits directly under the
548
571
  // trusted tmp boundary, so the ancestry walk degrades to one lstat + stat.
549
572
  assertSafeHarnessRoot(workspacesRoot, process.getuid?.() ?? -1);
550
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- evalsPath is our staged-subject path
551
573
  const suite = parseEvalSuite(readFileSync(evalsPath, 'utf-8'));
552
574
  return {
553
575
  workspacesRoot: stageEvalWorkspaces({
@@ -580,7 +602,8 @@ function attemptStageWorkspaces(evalsPath, harnessRoot, workspacesRoot, armDirs)
580
602
  if (e instanceof EvalInputError) {
581
603
  return {
582
604
  harnessPath: harnessRoot,
583
- exitCode: SkillTestExitCode.Preflight,
605
+ exitCode: ExitCode.ERROR,
606
+ reason: 'preflight',
584
607
  summary: `Eval input error:\n ${e.message}`,
585
608
  };
586
609
  }
@@ -590,8 +613,8 @@ function attemptStageWorkspaces(evalsPath, harnessRoot, workspacesRoot, armDirs)
590
613
  /**
591
614
  * Advisory (never-fatal) lint pass over the parsed suite: nudges authors away
592
615
  * from presence-only expectations ("mentions/includes …") that a hallucinated
593
- * or wrong-for-the-right-reason answer could still satisfy (issue #145
594
- * follow-up). Purely additive stderr noise, emitted before any spawn — never
616
+ * or wrong-for-the-right-reason answer could still satisfy.
617
+ * Purely additive stderr noise, emitted before any spawn — never
595
618
  * affects exitCode. Extracted so the Step 5.5 call site stays a single line
596
619
  * (keeps the orchestrator's cognitive complexity budget).
597
620
  */
@@ -765,7 +788,7 @@ export function buildDryRunSummary(input) {
765
788
  * evict staged untrusted skill bytes and prompts from OS tmp; `results/` is the
766
789
  * opposite — it is the run's product, written solely by vat.
767
790
  */
768
- export const RETAINED_RESULTS_DIRNAME = 'results';
791
+ const RETAINED_RESULTS_DIRNAME = 'results';
769
792
  /**
770
793
  * Remove the harness directory's staged contents after a run so untrusted skill
771
794
  * bytes and prompts do not accumulate in OS tmp. No-op when the user asked to keep
@@ -793,20 +816,20 @@ export const RETAINED_RESULTS_DIRNAME = 'results';
793
816
  * the run and cleanup is left in place rather than followed — `rmSync(recursive)`
794
817
  * could otherwise delete the symlink's target outside tmp.
795
818
  */
796
- export function cleanupHarness(harnessRoot, opts) {
819
+ function cleanupHarness(harnessRoot, opts) {
797
820
  if (opts.keep || !opts.created)
798
821
  return;
799
822
  // Best-effort: cleanup runs from a `finally`, so a TOCTOU race (the dir is
800
823
  // reaped between checks) or a permission error must never throw out and mask
801
- // the run's real outcome. Worst case is a leftover 0700 tmp dir, not a failure.
802
- try {
803
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
824
+ // the run's real outcome. Worst case is a leftover 0700 tmp dir, not a failure —
825
+ // but a leftover the operator was never told about is staged untrusted bytes
826
+ // sitting in tmp with nothing to connect them to this run, so the failure is
827
+ // REPORTED on stderr ({@link swallowCleanupFailure}), not swallowed silently.
828
+ swallowCleanupFailure(() => {
804
829
  if (!existsSync(harnessRoot))
805
830
  return;
806
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
807
831
  if (lstatSync(harnessRoot).isSymbolicLink())
808
832
  return;
809
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived harness root
810
833
  const entries = readdirSync(harnessRoot);
811
834
  // Nothing to retain — a run that ended before Step 7 (preflight refusal, lock
812
835
  // failure, a throw during staging) has no results/, so leaving an empty 0700
@@ -820,10 +843,7 @@ export function cleanupHarness(harnessRoot, opts) {
820
843
  continue;
821
844
  rmSync(safePath.joinUnderRoot(harnessRoot, entry), { recursive: true, force: true });
822
845
  }
823
- }
824
- catch {
825
- // Swallow: a failed cleanup is not a run failure.
826
- }
846
+ });
827
847
  }
828
848
  /**
829
849
  * Build the per-eval work items for a set of evals (one tier's worth, or a whole
@@ -833,7 +853,7 @@ export function cleanupHarness(harnessRoot, opts) {
833
853
  * (see {@link partitionFragmentsByArm}) and never drives tier gating. Pure +
834
854
  * unit-testable.
835
855
  */
836
- export function buildEvalWorkItems(evals, baseline) {
856
+ function buildEvalWorkItems(evals, baseline) {
837
857
  const items = [];
838
858
  for (const entry of evals) {
839
859
  items.push({ entry, arm: 'with' });
@@ -848,7 +868,7 @@ export function buildEvalWorkItems(evals, baseline) {
848
868
  * fragment with no `arm` (or `arm: 'with'`) is a WITH-arm fragment. Pure +
849
869
  * unit-testable.
850
870
  */
851
- export function partitionFragmentsByArm(fragments) {
871
+ function partitionFragmentsByArm(fragments) {
852
872
  const withArm = [];
853
873
  const withoutArm = [];
854
874
  for (const fragment of fragments) {
@@ -879,7 +899,7 @@ export function partitionFragmentsByArm(fragments) {
879
899
  * container; that is tracked as a follow-up (see CHANGELOG "Security" notes) and
880
900
  * is the only thing that closes the residual race outright.
881
901
  */
882
- export function resolveGraderOutDir(dirToken) {
902
+ function resolveGraderOutDir(dirToken) {
883
903
  return safePath.join(normalizedTmpdir(), `vat-skill-grade-${dirToken}`);
884
904
  }
885
905
  /**
@@ -903,7 +923,7 @@ export function resolveGraderOutDir(dirToken) {
903
923
  * told the control arm it was the control, and `../with/e1` told it where to look.
904
924
  * See {@link mintArmWorkspaceDirs}. Pure + unit-testable.
905
925
  */
906
- export function resolvePerEvalWorkspaceDir(entry, workspacesRoot, arm, armDirs) {
926
+ function resolvePerEvalWorkspaceDir(entry, workspacesRoot, arm, armDirs) {
907
927
  return safePath.joinUnderRoot(armWorkspaceRoot(workspacesRoot, armDirs, arm), String(entry.id));
908
928
  }
909
929
  /**
@@ -949,7 +969,7 @@ function armWorkspaceRoot(workspacesRoot, armDirs, arm) {
949
969
  * could not be removed will break the NEXT run with a "busy" error, and an operator
950
970
  * who saw nothing here has no way to connect the two.
951
971
  */
952
- export function swallowCleanupFailure(step) {
972
+ function swallowCleanupFailure(step) {
953
973
  try {
954
974
  step();
955
975
  }
@@ -957,25 +977,24 @@ export function swallowCleanupFailure(step) {
957
977
  process.stderr.write(`warning: harness cleanup step failed (the run's result stands): ${err instanceof Error ? err.message : String(err)}\n`);
958
978
  }
959
979
  }
960
- export function removeVatOnlyDir(dir) {
980
+ /**
981
+ * Remove one vat-only temp dir. Same discipline as {@link cleanupHarness}: a failed
982
+ * removal is not a run failure, and it is reported rather than swallowed.
983
+ */
984
+ function removeVatOnlyDir(dir) {
961
985
  if (dir === undefined)
962
986
  return;
963
- try {
964
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived vat-only tmp dir
987
+ swallowCleanupFailure(() => {
965
988
  if (!existsSync(dir))
966
989
  return;
967
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived vat-only tmp dir
968
990
  if (lstatSync(dir).isSymbolicLink())
969
991
  return;
970
992
  rmSync(dir, { recursive: true, force: true });
971
- }
972
- catch {
973
- // Swallow: a failed cleanup is not a run failure.
974
- }
993
+ });
975
994
  }
976
995
  /**
977
996
  * Run ONE work item: the blind executor spawn, then the grader spawn over its
978
- * captured transcript (issue #145). Returns the grader fragment tagged with the
997
+ * captured transcript. Returns the grader fragment tagged with the
979
998
  * item's arm. The WITHOUT arm runs the executor with `pluginDirs: []` (skill
980
999
  * absent). Grader fragments are written under a PER-ARM subdir of the vat-only
981
1000
  * grader dir so a WITH and WITHOUT run of the same eval id cannot collide.
@@ -983,7 +1002,7 @@ export function removeVatOnlyDir(dir) {
983
1002
  * Throws, and the caller ({@link runEvalWorker}) decides what a throw costs — see
984
1003
  * its docblock for why that depends on the arm. An executor CLEAN failure is NOT
985
1004
  * thrown on either arm: its transcript flows into the grader, whose failing
986
- * fragment surfaces as an eval failure (exit 4 via the verdict), never exit 1.
1005
+ * fragment surfaces as an eval failure (exit FINDINGS via the verdict), never ERROR.
987
1006
  */
988
1007
  /**
989
1008
  * The executor env for ONE eval.
@@ -1130,11 +1149,11 @@ async function gradeOneArm(item, ctx) {
1130
1149
  * TREATMENT arm: rethrow. `grading.json` is the treatment arm, the verdict is
1131
1150
  * computed from it, and a run that cannot produce one has produced nothing — there
1132
1151
  * is no partial result worth writing, so the throw propagates through `runPipeline`
1133
- * to `mapErrorToExitCode` exactly as before (exit 1).
1152
+ * to `skillTestFailureReason` exactly as before (`ERROR`, reason `internal`).
1134
1153
  *
1135
1154
  * CONTROL arm: record and continue. Every failure mode of this arm — executor spawn
1136
1155
  * 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
1156
+ * fragment unparseable twice, nonce mismatch, and a grader that returned a
1138
1157
  * different number of expectations than the eval declares — used to propagate out of
1139
1158
  * `runPipeline` and annihilate the treatment work already completed and billed. A
1140
1159
  * two-eval probe was measured doing precisely that: both treatment executors ran,
@@ -1160,11 +1179,46 @@ async function runEvalWorker(item, ctx) {
1160
1179
  return { fragment: await gradeOneArm(item, ctx) };
1161
1180
  }
1162
1181
  catch (err) {
1163
- if (err instanceof RateLimitSignal || item.arm === 'with')
1182
+ if (err instanceof RateLimitSignal)
1183
+ throw nameTheArm(err, item.arm);
1184
+ if (item.arm === 'with') {
1185
+ if (err instanceof GraderFragmentUnparseableError)
1186
+ return { fragment: unparseableGradingFragment(item, ctx, err) };
1164
1187
  throw nameTheArm(err, item.arm);
1188
+ }
1165
1189
  return recordControlFailure(item, err);
1166
1190
  }
1167
1191
  }
1192
+ /**
1193
+ * The TREATMENT arm's outcome for a grader that could not write valid JSON twice
1194
+ * ({@link GraderFragmentUnparseableError}): this eval FAILS, in the same fragment
1195
+ * shape a grader's own failing verdict takes, so it merges, counts and gates
1196
+ * exactly like one — `FAIL n/m`, `FINDINGS` unless `--allow-eval-failure`, the other
1197
+ * evals reported. Every declared expectation is marked failed with the reason as
1198
+ * its `evidence`; a `tool` verdict is NOT invented, because none was produced —
1199
+ * the eval already fails on the output channel, and `tool-eval.json` omitting it
1200
+ * is the truth. Not `InternalHarnessError`: the harness ran the grader twice and
1201
+ * it was the model that could not produce strict JSON.
1202
+ *
1203
+ * Treatment only. On the control arm the same error is recorded as a control
1204
+ * failure (delta withheld) — a synthesized control `fail` would read as lift.
1205
+ */
1206
+ function unparseableGradingFragment(item, ctx, err) {
1207
+ process.stderr.write(`\n⚠️ [${ARM_LABEL[item.arm]}] ${err.message}\n` +
1208
+ ` Eval "${err.evalId}" is reported as FAILED (${GRADER_FRAGMENT_UNPARSEABLE}); the run continues.\n`);
1209
+ return {
1210
+ runNonce: ctx.runNonce,
1211
+ evalId: err.evalId,
1212
+ arm: item.arm,
1213
+ expectations: item.entry.expectations.map((text) => ({
1214
+ // Suite text enters an artifact here without a grader's echo in between, so
1215
+ // it takes the same sanitizer a grader's echo would have.
1216
+ text: sanitizeGraderText(text),
1217
+ passed: false,
1218
+ evidence: `${GRADER_FRAGMENT_UNPARSEABLE}: ${err.detail}`,
1219
+ })),
1220
+ };
1221
+ }
1168
1222
  /**
1169
1223
  * The control arm ran out of rate-limit retries — record it and let the run live.
1170
1224
  *
@@ -1182,7 +1236,7 @@ async function runEvalWorker(item, ctx) {
1182
1236
  * Wired as the pipeline's `onRetriesExhausted`, and `--baseline` — which doubles the
1183
1237
  * spawn count — is the run most likely to need it.
1184
1238
  */
1185
- export function rateLimitExhaustedOutcome(item, error) {
1239
+ function rateLimitExhaustedOutcome(item, error) {
1186
1240
  if (item.arm === 'with')
1187
1241
  throw nameTheArm(error, item.arm);
1188
1242
  return recordControlFailure(item, error);
@@ -1227,7 +1281,7 @@ const VAT_ATTACHED_FRAGMENT_KEYS = ['contamination', 'degraded'];
1227
1281
  * scanned properly, and an operator who learns to ignore that warning stops reading
1228
1282
  * the real ones.
1229
1283
  */
1230
- export function withoutGraderContamination(fragment) {
1284
+ function withoutGraderContamination(fragment) {
1231
1285
  if (VAT_ATTACHED_FRAGMENT_KEYS.every((key) => fragment[key] === undefined))
1232
1286
  return fragment;
1233
1287
  const copy = { ...fragment };
@@ -1251,8 +1305,8 @@ const ARM_LABEL = {
1251
1305
  /**
1252
1306
  * Prefix a worker error's message with the arm it came from, IN PLACE.
1253
1307
  *
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
1308
+ * Mutating rather than re-wrapping is deliberate. `skillTestFailureReason` reads the
1309
+ * error's own declared reason, and the run's regression test for a forged grader
1256
1310
  * nonce (`rejects.toThrow(GradingNonceError)`); wrapping every failure in a fresh
1257
1311
  * `InternalHarnessError` to get a better message would flatten
1258
1312
  * `EvalFragmentError`/`GradingNonceError`/`RateLimitSignal` into one class and lose
@@ -1262,9 +1316,7 @@ const ARM_LABEL = {
1262
1316
  * must not accumulate prefixes.
1263
1317
  */
1264
1318
  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}`;
1319
+ prefixMessageOnce(err, `[${ARM_LABEL[arm]}] `);
1268
1320
  return err;
1269
1321
  }
1270
1322
  /**
@@ -1288,13 +1340,14 @@ function nameTheArm(err, arm) {
1288
1340
  * Fixtures are read from the STAGED workspace rather than the suite directory, for
1289
1341
  * the same reason the SKILL.md is: that is byte-for-byte what the arm was handed.
1290
1342
  *
1291
- * Returns `[]` when the file cannot be read. A missing SKILL.md is not this
1343
+ * Returns `[]` when there is no staged SKILL.md. A missing SKILL.md is not this
1292
1344
  * 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.
1345
+ * unarmed signal is reported as unarmed rather than claimed as clean. A SKILL.md
1346
+ * that is THERE but refused is a different thing — `[]` would silently disarm the
1347
+ * contamination signal for the whole run — so a refusal propagates. The same split
1348
+ * applies to fixtures: see {@link collectFixtureText}.
1296
1349
  */
1297
- export function resolveSkillContentNeedles(subjectStagedDir, evals,
1350
+ function resolveSkillContentNeedles(subjectStagedDir, evals,
1298
1351
  /**
1299
1352
  * Where the staged fixtures live, as the two inputs an arm root is DERIVED from
1300
1353
  * rather than as the pre-joined path itself — see {@link armWorkspaceRoot}. Taking
@@ -1309,11 +1362,12 @@ workspaces) {
1309
1362
  const skillMdPath = safePath.join(subjectStagedDir, 'SKILL.md');
1310
1363
  let markdown;
1311
1364
  try {
1312
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path derived from vat's own staged dir
1313
1365
  markdown = readFileSync(skillMdPath, 'utf8');
1314
1366
  }
1315
- catch {
1316
- return [];
1367
+ catch (err) {
1368
+ if (isPathAbsentError(err))
1369
+ return [];
1370
+ throw err;
1317
1371
  }
1318
1372
  const excluded = evals
1319
1373
  .flatMap((entry) => [
@@ -1326,7 +1380,8 @@ workspaces) {
1326
1380
  return skillContentNeedles(markdown, excluded);
1327
1381
  }
1328
1382
  /**
1329
- * The text of one eval's staged input files. Unreadable/binary fixtures are skipped.
1383
+ * The text of one eval's staged input files. A declared fixture that was never
1384
+ * staged is skipped; one that is there but cannot be read propagates.
1330
1385
  *
1331
1386
  * A declared `files[]` entry is legitimately a DIRECTORY — staging does `existsSync`
1332
1387
  * then a recursive `cpSync` — so this walks one. It used to call `readFileSync` on
@@ -1339,9 +1394,11 @@ workspaces) {
1339
1394
  *
1340
1395
  * The `catch`'s old claim that skipping "errs the safe way: noisier, never blinder"
1341
1396
  * 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.
1397
+ * `contaminated: true` is actioned by discarding a run that was fine. So only an
1398
+ * ABSENT fixture is skipped (staging does `existsSync` then `cpSync`, so a declared
1399
+ * file missing at the source is simply not in the workspace); a fixture that is
1400
+ * there and refused throws, because an exclusion set vat could not compute is a
1401
+ * signal vat cannot stand behind.
1345
1402
  */
1346
1403
  function readStagedFixtures(stagedWorkspaceRoot, entry) {
1347
1404
  const texts = [];
@@ -1353,33 +1410,44 @@ function readStagedFixtures(stagedWorkspaceRoot, entry) {
1353
1410
  }
1354
1411
  /** Depth cap on the fixture walk — a symlink loop must not hang the run's setup. */
1355
1412
  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;
1413
+ /**
1414
+ * Append the declared fixture's text — the file's, or every file's under it when
1415
+ * it is a directory. `lstat` first, so the ONE absence case (`ENOENT`, or `ENOTDIR`
1416
+ * for a path routed through a file) is the only thing skipped; a symlink at the
1417
+ * declared path is skipped too, for the same reason the walk below skips one.
1418
+ */
1419
+ function collectFixtureText(path, into) {
1420
+ let kind;
1361
1421
  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 });
1422
+ kind = lstatSync(path);
1367
1423
  }
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;
1424
+ catch (err) {
1425
+ if (isPathAbsentError(err))
1426
+ return;
1427
+ throw err;
1378
1428
  }
1379
- for (const child of entries) {
1380
- if (!child.isFile() && !child.isDirectory())
1429
+ if (kind.isFile())
1430
+ into.push(readFileSync(path, 'utf8'));
1431
+ else if (kind.isDirectory())
1432
+ walkFixtureDir(path, into, 0);
1433
+ }
1434
+ /** Append every file's text under `dir`, recursing. Symlinked entries are skipped. */
1435
+ function walkFixtureDir(dir, into, depth) {
1436
+ if (depth > MAX_FIXTURE_WALK_DEPTH)
1437
+ return;
1438
+ // `withFileTypes` on the DIRECTORY is one syscall for the whole level, and
1439
+ // `lstat`-shaped: a symlinked entry reports as a link rather than as whatever it
1440
+ // points at, so the walk stays inside the bytes vat actually staged.
1441
+ for (const child of readdirSync(dir, { withFileTypes: true })) {
1442
+ const childPath = safePath.joinUnderRoot(dir, child.name);
1443
+ const kind = direntKind(child);
1444
+ // A link is skipped by design (see above): the walk reads only staged bytes.
1445
+ if (kind === 'symlink')
1381
1446
  continue;
1382
- collectFixtureText(safePath.joinUnderRoot(path, child.name), into, depth + 1);
1447
+ if (kind === 'file')
1448
+ into.push(readFileSync(childPath, 'utf8'));
1449
+ else if (kind === 'directory')
1450
+ walkFixtureDir(childPath, into, depth + 1);
1383
1451
  }
1384
1452
  }
1385
1453
  /**
@@ -1411,7 +1479,7 @@ function collectFixtureText(path, into, depth = 0) {
1411
1479
  * not to run. That is the same shape as the dropped `armCwd` that anchored the whole
1412
1480
  * walk one directory too high, also green.
1413
1481
  */
1414
- export function buildContaminationInput(transcript, ctx, evalId) {
1482
+ function buildContaminationInput(transcript, ctx, evalId) {
1415
1483
  return contaminationInput(transcript, ctx, evalId);
1416
1484
  }
1417
1485
  /**
@@ -1419,7 +1487,7 @@ export function buildContaminationInput(transcript, ctx, evalId) {
1419
1487
  * once per run with no transcript and no eval — so it can honestly supply neither,
1420
1488
  * and `armCwd` is correctly absent rather than accidentally dropped.
1421
1489
  */
1422
- export function buildContaminationSignalsInput(ctx) {
1490
+ function buildContaminationSignalsInput(ctx) {
1423
1491
  return contaminationInput('', ctx, undefined);
1424
1492
  }
1425
1493
  function contaminationInput(transcript, ctx, evalId) {
@@ -1500,7 +1568,7 @@ function contaminationSignalsFor(ctx) {
1500
1568
  }
1501
1569
  /**
1502
1570
  * Run evals TIER by TIER (ascending), bounded-parallel WITHIN each tier, with a
1503
- * GATE between tiers (issue #145 Phase G). After a tier completes, apply the
1571
+ * GATE between tiers. After a tier completes, apply the
1504
1572
  * default gate policy ({@link shouldGateAfterTier}) over that tier's WITH-arm
1505
1573
  * fragments: if any eval in the tier did not fully pass, do NOT launch the higher
1506
1574
  * (more expensive) tiers — their evals are recorded as SKIPPED (a distinct state,
@@ -1540,7 +1608,7 @@ async function runEvalsTiered(input) {
1540
1608
  /** Resolve the run's grading/friction/baseline/tool-eval artifact paths under
1541
1609
  * `resultsDir`. Single source of truth for the filenames (used by the
1542
1610
  * pre-pipeline stale wipe AND the post-merge writer). */
1543
- export function resolveArtifactPaths(resultsDir) {
1611
+ function resolveArtifactPaths(resultsDir) {
1544
1612
  return {
1545
1613
  gradingOut: safePath.join(resultsDir, 'grading.json'),
1546
1614
  frictionOut: safePath.join(resultsDir, 'friction.json'),
@@ -1557,7 +1625,7 @@ export function resolveArtifactPaths(resultsDir) {
1557
1625
  * run's. Wiping all three up front closes that cross-run leak. Best-effort
1558
1626
  * (`force: true`) — a missing file is fine.
1559
1627
  */
1560
- export function wipeStaleArtifacts(paths) {
1628
+ function wipeStaleArtifacts(paths) {
1561
1629
  for (const path of [paths.gradingOut, paths.frictionOut, paths.baselineOut, paths.toolEvalOut]) {
1562
1630
  rmSync(path, { force: true });
1563
1631
  // ...and the quarantined copy a PRIOR run's failed gate set aside. It is just as
@@ -1589,7 +1657,6 @@ function openResultsDir(input) {
1589
1657
  // input files ends up here as text — and unlike the workspaces, results/ SURVIVES
1590
1658
  // `--keep` by design.
1591
1659
  mkdirSyncReal(input.resultsDir, { recursive: true, mode: 0o700 });
1592
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1593
1660
  writeFileSync(input.provenancePath, JSON.stringify(input.provenance, null, 2) + '\n', 'utf-8');
1594
1661
  }
1595
1662
  /**
@@ -1602,7 +1669,7 @@ function openResultsDir(input) {
1602
1669
  * a maintainer needs in order to see which refine tripped, and a name ending in
1603
1670
  * `.rejected` cannot be mistaken for the artifact a reader came for.
1604
1671
  */
1605
- export function rejectedArtifactPath(path) {
1672
+ function rejectedArtifactPath(path) {
1606
1673
  return `${path}.rejected`;
1607
1674
  }
1608
1675
  /**
@@ -1632,7 +1699,7 @@ export function rejectedArtifactPath(path) {
1632
1699
  * everything it declared, so the two fields were numerically identical in all of
1633
1700
  * them and the ONE thing this function exists to keep distinct was unpinned.
1634
1701
  */
1635
- export function gradedCounts(fragments) {
1702
+ function gradedCounts(fragments) {
1636
1703
  return fragments.map((f) => ({
1637
1704
  evalId: f.evalId,
1638
1705
  passed: f.expectations.filter((e) => e.passed).length,
@@ -1716,7 +1783,7 @@ function deltaTruncationFor(skipped) {
1716
1783
  * on a run where they can be. A contaminated delta is also diagnostic in its own
1717
1784
  * right: it collapses toward zero, which is corroboration, not noise.
1718
1785
  */
1719
- export function formatBaselineReport(delta, integrity) {
1786
+ function formatBaselineReport(delta, integrity) {
1720
1787
  const line = `\n${formatBaselineDeltaLine(delta)}\n`;
1721
1788
  // Any of the three makes the delta unusable-as-stated, and all three are only
1722
1789
  // visible on stderr — nobody opens baseline.json unprompted.
@@ -1724,20 +1791,24 @@ export function formatBaselineReport(delta, integrity) {
1724
1791
  return line;
1725
1792
  return `${line}\n⚠️ ${integrity.summary}\n\n`;
1726
1793
  }
1794
+ const IN_PLACE_SUBJECT_FRICTION = {
1795
+ severity: 'low',
1796
+ category: 'path-assumption',
1797
+ message: 'In-place skill (publish: false) — staged from source; links that leave the skill directory are not ' +
1798
+ 'present in the harness, so an eval that depends on them tests the skill without them.',
1799
+ };
1727
1800
  function writeRunArtifactsAndReconcile(input) {
1728
1801
  const { fragments, runNonce, paths, signals, controlFailures } = input;
1729
1802
  const { withArm, withoutArm } = partitionFragmentsByArm(fragments);
1730
1803
  let baselineReport;
1731
1804
  const grading = mergeFragmentsToGrading(withArm, runNonce, 'with');
1732
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1733
1805
  writeFileSync(paths.gradingOut, JSON.stringify(grading, null, 2) + '\n', 'utf-8');
1734
- const friction = mergeFragmentsToFriction(fragments);
1735
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1806
+ const merged = mergeFragmentsToFriction(fragments);
1807
+ const friction = input.subjectInPlace ? { items: [IN_PLACE_SUBJECT_FRICTION, ...merged.items] } : merged;
1736
1808
  writeFileSync(paths.frictionOut, JSON.stringify(friction, null, 2) + '\n', 'utf-8');
1737
1809
  // Tool verdicts come from the WITH arm ONLY — the WITHOUT/skill-absent arm never
1738
1810
  // carries toolExpectations, so its fragments have no `tool` body to merge.
1739
1811
  const toolEval = mergeFragmentsToToolEval(withArm);
1740
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1741
1812
  writeFileSync(paths.toolEvalOut, JSON.stringify(toolEval, null, 2) + '\n', 'utf-8');
1742
1813
  // `controlFailures` is the second disjunct, and it is load-bearing: when EVERY
1743
1814
  // control eval died there are no WITHOUT-arm fragments at all, and gating on the
@@ -1817,7 +1888,6 @@ function writeRunArtifactsAndReconcile(input) {
1817
1888
  // qualifies it would also mean a reader who wants only the lift has to
1818
1889
  // understand the contamination vocabulary first. (GradingReportSchema is
1819
1890
  // .passthrough(), so both extra keys are contract-legal — see the comment above.)
1820
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1821
1891
  writeFileSync(paths.baselineOut, JSON.stringify({ ...baseline, baselineIntegrity: integrity, baselineDelta: delta }, null, 2) + '\n', 'utf-8');
1822
1892
  // RETURNED, not written. The delta line and the ⚠️ banner must not be separated
1823
1893
  // from each other by the friction report: `emitFrictionReport` runs from the
@@ -1839,28 +1909,28 @@ function writeRunArtifactsAndReconcile(input) {
1839
1909
  };
1840
1910
  }
1841
1911
  /**
1842
- * The COMPOSITE run verdict (issue #145 Phase T): the run passes only when BOTH
1912
+ * The COMPOSITE run verdict: the run passes only when BOTH
1843
1913
  * the prose-expectation grading passed AND every tool-expectation verdict passed.
1844
1914
  * Tool verdicts live in tool-eval.json (a SEPARATE channel — C2); this combines
1845
1915
  * the two at the exit-code layer WITHOUT mixing the channels' data. When no eval
1846
1916
  * declared `toolExpectations`, `toolEval.evals` is empty and this equals
1847
1917
  * `outputAllPassed`. Pure + unit-testable.
1848
1918
  */
1849
- export function computeCompositeVerdict(outputAllPassed, toolEval) {
1919
+ function computeCompositeVerdict(outputAllPassed, toolEval) {
1850
1920
  return outputAllPassed && toolEval.evals.every((v) => v.passed);
1851
1921
  }
1852
1922
  /**
1853
1923
  * The run's final pass/fail after cost-tiered fail-fast: the {@link
1854
1924
  * computeCompositeVerdict} of the tiers that RAN, AND no tiers were skipped. A
1855
1925
  * fail-fast run that gated higher tiers is NEVER a pass (skipped ≠ passed) — this
1856
- * forces `false` so the exit code is EvalFailure (4), never downgraded to 0 by the
1926
+ * forces `false` so the exit code is FINDINGS, never downgraded to 0 by the
1857
1927
  * composite path alone. Pure + unit-testable.
1858
1928
  */
1859
- export function resolveCompositeAllPassed(outputAllPassed, toolEval, skipped) {
1929
+ function resolveCompositeAllPassed(outputAllPassed, toolEval, skipped) {
1860
1930
  return computeCompositeVerdict(outputAllPassed, toolEval) && skipped === undefined;
1861
1931
  }
1862
1932
  /** Fold one session's `total_cost_usd` into the accumulator; a non-number (mock spawn / missing result) is ignored. */
1863
- export function recordSessionCost(acc, totalCostUsd) {
1933
+ function recordSessionCost(acc, totalCostUsd) {
1864
1934
  if (typeof totalCostUsd !== 'number' || !Number.isFinite(totalCostUsd))
1865
1935
  return;
1866
1936
  acc.totalUsd += totalCostUsd;
@@ -1871,7 +1941,7 @@ export function recordSessionCost(acc, totalCostUsd) {
1871
1941
  * when no session reported a cost (e.g. every spawn was a test mock) so the suffix
1872
1942
  * never adds noise to a run with no cost signal. Pure + unit-testable.
1873
1943
  */
1874
- export function formatRunCostSuffix(cost) {
1944
+ function formatRunCostSuffix(cost) {
1875
1945
  if (cost === undefined || cost.sessions === 0)
1876
1946
  return '';
1877
1947
  return ` | ≈$${cost.totalUsd.toFixed(2)} across ${cost.sessions} session${cost.sessions === 1 ? '' : 's'}`;
@@ -1883,7 +1953,7 @@ export function formatRunCostSuffix(cost) {
1883
1953
  * verdict failed, a `(N tool)` suffix names how many — so a composite FAIL whose
1884
1954
  * OUTPUT counts look all-green (e.g. `FAIL 3/3 (1 tool)`) is self-explaining. Pure.
1885
1955
  */
1886
- export function buildRunSummary(verdict, toolEval, compositeAllPassed) {
1956
+ function buildRunSummary(verdict, toolEval, compositeAllPassed) {
1887
1957
  const base = `${compositeAllPassed ? 'PASS' : 'FAIL'} ${verdict.passed}/${verdict.total}`;
1888
1958
  const toolFailures = toolEval.evals.filter((v) => !v.passed).length;
1889
1959
  return toolFailures > 0 ? `${base} (${toolFailures} tool)` : base;
@@ -1895,7 +1965,7 @@ export function buildRunSummary(verdict, toolEval, compositeAllPassed) {
1895
1965
  * tiers ALWAYS reads FAIL (skipped ≠ passed forces `compositeAllPassed` false at
1896
1966
  * the call site), so the base line is already FAIL when the note is present.
1897
1967
  */
1898
- export function buildRunSummaryWithSkips(verdict, toolEval, compositeAllPassed, skipped, cost) {
1968
+ function buildRunSummaryWithSkips(verdict, toolEval, compositeAllPassed, skipped, cost) {
1899
1969
  // The spend suffix rides the verdict line (like `(N tool)`); the skipped-tiers
1900
1970
  // note stays on its own following line.
1901
1971
  const base = buildRunSummary(verdict, toolEval, compositeAllPassed) + formatRunCostSuffix(cost);
@@ -1909,13 +1979,11 @@ export function buildRunSummaryWithSkips(verdict, toolEval, compositeAllPassed,
1909
1979
  * safe precisely because vat always writes these after the merge succeeds.
1910
1980
  */
1911
1981
  function assertVatWroteArtifact(path, validate, label) {
1912
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1913
1982
  if (!existsSync(path)) {
1914
1983
  throw new InternalHarnessError(`vat did not write ${label} at ${path} after the merge — harness bug.`);
1915
1984
  }
1916
1985
  let raw;
1917
1986
  try {
1918
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1919
1987
  raw = JSON.parse(readFileSync(path, 'utf-8'));
1920
1988
  }
1921
1989
  catch (err) {
@@ -1938,23 +2006,28 @@ function assertVatWroteArtifact(path, validate, label) {
1938
2006
  * failing, and a cleanup that replaces the real diagnosis with a rename error would
1939
2007
  * be strictly worse than a file left in place. If the rename fails the file is
1940
2008
  * removed instead — an absent artifact reads as "vat wrote nothing", which is at
1941
- * least not a false claim of authority.
2009
+ * least not a false claim of authority. If THAT fails too, the file is still there
2010
+ * under its authoritative name, and the returned clause says so with both errors:
2011
+ * the operator is about to read a CI archive, and a bare '' told them nothing.
1942
2012
  */
1943
2013
  function quarantineArtifact(path) {
1944
2014
  const rejected = rejectedArtifactPath(path);
2015
+ let renameFailure;
1945
2016
  try {
1946
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
1947
2017
  renameSync(path, rejected);
1948
2018
  return `; moved aside to ${rejected} so it is not read as authoritative`;
1949
2019
  }
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
- }
2020
+ catch (err) {
2021
+ renameFailure = err instanceof Error ? err.message : String(err);
2022
+ }
2023
+ try {
2024
+ rmSync(path, { force: true });
2025
+ return '; removed so it is not read as authoritative';
2026
+ }
2027
+ catch (err) {
2028
+ return (`; it could not be moved aside (${renameFailure}) or removed ` +
2029
+ `(${err instanceof Error ? err.message : String(err)}), so it is STILL THERE under its ` +
2030
+ 'authoritative name — do not read it as authoritative');
1958
2031
  }
1959
2032
  }
1960
2033
  /**
@@ -1977,7 +2050,7 @@ function quarantineArtifact(path) {
1977
2050
  * baseline and wrote nothing), which is exactly what a fail-CLOSED gate must not do.
1978
2051
  * A non-baseline run writes no baseline.json at all and is not asked about one.
1979
2052
  */
1980
- export function assertVatWroteArtifacts(paths, baselineRun) {
2053
+ function assertVatWroteArtifacts(paths, baselineRun) {
1981
2054
  assertVatWroteArtifact(paths.gradingOut, (raw) => { GradingReportSchema.parse(raw); }, 'grading.json');
1982
2055
  assertVatWroteArtifact(paths.frictionOut, (raw) => { FrictionReportSchema.parse(raw); }, 'friction.json');
1983
2056
  assertVatWroteArtifact(paths.toolEvalOut, (raw) => { ToolEvalReportSchema.parse(raw); }, 'tool-eval.json');
@@ -2008,7 +2081,7 @@ export function assertVatWroteArtifacts(paths, baselineRun) {
2008
2081
  * `harnessCreated` is true only for the derived-under-OS-tmp case; an explicit
2009
2082
  * `--out` or `--workdir` is a location the user owns and is never auto-removed.
2010
2083
  */
2011
- export function resolveHarnessLocation(opts) {
2084
+ function resolveHarnessLocation(opts) {
2012
2085
  if (opts.out !== undefined && opts.workdir !== undefined) {
2013
2086
  throw new HarnessLocationError('--out and --workdir are mutually exclusive: --out names the harness root exactly, ' +
2014
2087
  '--workdir names the base it is derived under. Pass one.');
@@ -2184,7 +2257,6 @@ export async function runSkillTestHarness(opts) {
2184
2257
  // "can the executor reach the answer key" is not to write it there.
2185
2258
  // Same predicate as `resolveEvalSuitePath`'s rule 1, so the two cannot disagree
2186
2259
  // about which copy this run uses.
2187
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- authored source path resolved from opts
2188
2260
  const authoredSuiteExists = existsSync(resolveScaffoldEvalsPath(opts, repoRoot, evalsRef));
2189
2261
  const stageResult = await stageHarness({
2190
2262
  harnessRoot,
@@ -2204,7 +2276,7 @@ export async function runSkillTestHarness(opts) {
2204
2276
  // staged tree — the answer key must never be reachable by the executor — so this
2205
2277
  // resolves to the AUTHORED source copy, or to the vat-only hold dir when the
2206
2278
  // suite existed nowhere but inside a fetched artifact. Absent from both →
2207
- // bootstrap a template at the source scaffold and exit 3, so an authored suite
2279
+ // bootstrap a template at the source scaffold and exit 2 with `Reason: bootstrap`, so an authored suite
2208
2280
  // is never overwritten.
2209
2281
  const evalsPath = resolveEvalSuitePath({
2210
2282
  opts,
@@ -2222,7 +2294,8 @@ export async function runSkillTestHarness(opts) {
2222
2294
  const summary = renderPreflightSummary(preflightResult.checks);
2223
2295
  return {
2224
2296
  harnessPath: harnessRoot,
2225
- exitCode: SkillTestExitCode.Preflight,
2297
+ exitCode: ExitCode.ERROR,
2298
+ reason: 'preflight',
2226
2299
  summary: `Preflight failed:\n${summary}`,
2227
2300
  };
2228
2301
  }
@@ -2242,7 +2315,8 @@ export async function runSkillTestHarness(opts) {
2242
2315
  if (!isAcknowledged(opts)) {
2243
2316
  return {
2244
2317
  harnessPath: harnessRoot,
2245
- exitCode: SkillTestExitCode.Preflight,
2318
+ exitCode: ExitCode.ERROR,
2319
+ reason: 'preflight',
2246
2320
  summary: 'Security acknowledgment required. Pass --i-understand-this-runs-skill-code to proceed.',
2247
2321
  };
2248
2322
  }
@@ -2301,7 +2375,7 @@ export async function runSkillTestHarness(opts) {
2301
2375
  if (isDryRun) {
2302
2376
  return {
2303
2377
  harnessPath: harnessRoot,
2304
- exitCode: SkillTestExitCode.Ok,
2378
+ exitCode: ExitCode.OK,
2305
2379
  summary: buildDryRunSummary({
2306
2380
  wouldBuild: opts.wouldBuild === true,
2307
2381
  ...(opts.dryRunStagedExistingDist === undefined
@@ -2374,7 +2448,7 @@ export async function runSkillTestHarness(opts) {
2374
2448
  ...(opts.declaredExecutables === undefined ? {} : { declaredExecutables: opts.declaredExecutables }),
2375
2449
  ...(opts.spawn === undefined ? {} : { spawn: opts.spawn }),
2376
2450
  };
2377
- // Tier-ordered, cost-tiered fail-fast (issue #145 Phase G): run evals tier by
2451
+ // Tier-ordered, cost-tiered fail-fast: run evals tier by
2378
2452
  // tier (ascending / cheapest first), bounded-parallel WITHIN each tier, and
2379
2453
  // gate BETWEEN tiers — once a cheaper tier fails a gating expectation, the
2380
2454
  // higher (more expensive) tiers are SKIPPED (never graded, never passed), so a
@@ -2382,10 +2456,13 @@ export async function runSkillTestHarness(opts) {
2382
2456
  // Each tier is one runPipeline: bounded-parallel, retrying a RateLimitSignal
2383
2457
  // per item. An InternalHarnessError thrown by a TREATMENT-arm executor/grader
2384
2458
  // (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.
2459
+ // declared-count mismatch) propagates OUT unhandled → skillTestFailureReason →
2460
+ // ERROR/internal; a spawn or grader break is never laundered into a pass/fail verdict
2461
+ // (R1 no-laundering). The one grader outcome that IS a verdict — a fragment the
2462
+ // model could not write as valid JSON even after one re-grade — fails that eval
2463
+ // (see unparseableGradingFragment). The same break on the CONTROL arm is
2464
+ // recorded and the run continues — see runEvalWorker for why the two arms are
2465
+ // not symmetric.
2389
2466
  const { fragments, controlFailures, skipped } = await runEvalsTiered({
2390
2467
  evals: suite.evals,
2391
2468
  baseline: opts.baseline === true,
@@ -2404,7 +2481,7 @@ export async function runSkillTestHarness(opts) {
2404
2481
  // tool-eval.json (and baseline.json for a baseline run), then reconciles the
2405
2482
  // authoritative prose-expectation verdict from the WITH-arm per-expectation
2406
2483
  // `passed` flags — NOT any self-reported summary. An executor CLEAN failure
2407
- // reaches here as a FAILing fragment → composite verdict → exit 4.
2484
+ // reaches here as a FAILing fragment → composite verdict → exit FINDINGS.
2408
2485
  const { verdict, toolEval, baselineReport } = writeRunArtifactsAndReconcile({
2409
2486
  fragments,
2410
2487
  runNonce,
@@ -2414,6 +2491,7 @@ export async function runSkillTestHarness(opts) {
2414
2491
  // `skipped` was in scope here and simply not passed, which is how the delta
2415
2492
  // block and its printed line came to describe a truncated run as a complete one.
2416
2493
  skipped,
2494
+ subjectInPlace: opts.subjectInPlace,
2417
2495
  });
2418
2496
  // D2 fail-closed gate: vat is the SOLE writer of results/, so a missing/unparseable/
2419
2497
  // invalid grading.json, friction.json, tool-eval.json — or, on a baseline run,
@@ -2433,10 +2511,10 @@ export async function runSkillTestHarness(opts) {
2433
2511
  emitBaselineReport(baselineReport);
2434
2512
  // Composite verdict: the run passes only when BOTH output expectations AND every
2435
2513
  // tool-expectation verdict passed — so an output all-pass where a `mustRun` never
2436
- // ran yields FAIL → exit 4. Tool verdicts stay in tool-eval.json (C2); the composite
2514
+ // ran yields FAIL → exit FINDINGS. Tool verdicts stay in tool-eval.json (C2); the composite
2437
2515
  // combines the two channels only here, at the exit-code layer. A fail-fast run
2438
2516
  // that SKIPPED higher tiers is never a pass: skipped ≠ passed forces allPassed
2439
- // false → exit 4 (never downgraded to 0 by the composite path).
2517
+ // false → exit FINDINGS (never downgraded to 0 by the composite path).
2440
2518
  const allPassed = resolveCompositeAllPassed(verdict.allPassed, toolEval, skipped);
2441
2519
  const summary = buildRunSummaryWithSkips(verdict, toolEval, allPassed, skipped, costAccumulator);
2442
2520
  return {
@@ -2466,4 +2544,62 @@ export async function runSkillTestHarness(opts) {
2466
2544
  cleanup();
2467
2545
  }
2468
2546
  }
2547
+ /**
2548
+ * The test-facing seam — every helper the unit tests reach that is NOT part of
2549
+ * this module's API.
2550
+ *
2551
+ * These used to be forty-four separate `export`s, indistinguishable at the export
2552
+ * keyword from the seven names the skill-test barrel actually publishes, so
2553
+ * "is this API?" was a question for a JSDoc rather than a grep. Now it is a
2554
+ * grep: a name here is reachable by a test and by nothing else. Nothing under
2555
+ * `src/` imports `__internal` and no barrel re-exports it — a dev-tools test
2556
+ * holds both lines. Types stay exported on their own: an object cannot carry
2557
+ * them, and a type is not a call site.
2558
+ */
2559
+ export const __internal = {
2560
+ assertVatWroteArtifacts,
2561
+ buildContaminationInput,
2562
+ buildContaminationSignalsInput,
2563
+ buildEvalWorkItems,
2564
+ buildFlagParseProbe,
2565
+ buildPreflightInput,
2566
+ buildResolveCtx,
2567
+ buildRunSummary,
2568
+ buildRunSummaryWithSkips,
2569
+ computeCompositeVerdict,
2570
+ detectItemPluginLayout,
2571
+ emitFrictionReport,
2572
+ FLAG_PROBE_SENTINEL,
2573
+ formatBaselineReport,
2574
+ formatRunCostSuffix,
2575
+ gradedCounts,
2576
+ helpTextDeclaresFlag,
2577
+ makeStageItem,
2578
+ mintArmWorkspaceDirs,
2579
+ partitionFragmentsByArm,
2580
+ recordSessionCost,
2581
+ rejectedArtifactPath,
2582
+ removeVatOnlyDir,
2583
+ renderPreflightSummary,
2584
+ resolveArtifactPaths,
2585
+ resolveCompositeAllPassed,
2586
+ resolveHarnessLocation,
2587
+ resolveKnobs,
2588
+ resolvePerEvalWorkspaceDir,
2589
+ resolveScaffoldEvalsPath,
2590
+ resolveSkillContentNeedles,
2591
+ resolveStallMs,
2592
+ resolveTimeoutMs,
2593
+ resolveWorkspacesRoot,
2594
+ RETAINED_RESULTS_DIRNAME,
2595
+ stageWorkspacesForRun,
2596
+ subjectSkillName,
2597
+ swallowCleanupFailure,
2598
+ wipeStaleArtifacts,
2599
+ withoutGraderContamination,
2600
+ cleanupHarness,
2601
+ buildStageItems,
2602
+ resolveGraderOutDir,
2603
+ resolveEvalSuitePath,
2604
+ };
2469
2605
  //# sourceMappingURL=run-harness.js.map