@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.
- package/README.md +5 -5
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +7 -17
- package/dist/builder.js.map +1 -1
- package/dist/files-config.d.ts +2 -2
- package/dist/files-config.d.ts.map +1 -1
- package/dist/files-config.js +1 -7
- package/dist/files-config.js.map +1 -1
- package/dist/import.d.ts.map +1 -1
- package/dist/import.js +0 -4
- package/dist/import.js.map +1 -1
- package/dist/inventory/types.d.ts +8 -0
- package/dist/inventory/types.d.ts.map +1 -1
- package/dist/inventory/types.js.map +1 -1
- package/dist/plugin-distribution-layout.d.ts.map +1 -1
- package/dist/plugin-distribution-layout.js +0 -2
- package/dist/plugin-distribution-layout.js.map +1 -1
- package/dist/post-build-checks.d.ts.map +1 -1
- package/dist/post-build-checks.js +12 -11
- package/dist/post-build-checks.js.map +1 -1
- package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
- package/dist/schemas/installed-plugins-registry.js +7 -1
- package/dist/schemas/installed-plugins-registry.js.map +1 -1
- package/dist/skill-identity.d.ts +8 -3
- package/dist/skill-identity.d.ts.map +1 -1
- package/dist/skill-identity.js +13 -6
- package/dist/skill-identity.js.map +1 -1
- package/dist/skill-packager.d.ts +2 -1
- package/dist/skill-packager.d.ts.map +1 -1
- package/dist/skill-packager.js +47 -40
- package/dist/skill-packager.js.map +1 -1
- package/dist/skill-source/content-hash.d.ts.map +1 -1
- package/dist/skill-source/content-hash.js +21 -10
- package/dist/skill-source/content-hash.js.map +1 -1
- package/dist/skill-source/fetch-cache.d.ts.map +1 -1
- package/dist/skill-source/fetch-cache.js +0 -5
- package/dist/skill-source/fetch-cache.js.map +1 -1
- package/dist/skill-source/git-clone.d.ts.map +1 -1
- package/dist/skill-source/git-clone.js +0 -2
- package/dist/skill-source/git-clone.js.map +1 -1
- package/dist/skill-source/sources/url-source.d.ts.map +1 -1
- package/dist/skill-source/sources/url-source.js +0 -1
- package/dist/skill-source/sources/url-source.js.map +1 -1
- package/dist/skill-source/stage.d.ts.map +1 -1
- package/dist/skill-source/stage.js +0 -6
- package/dist/skill-source/stage.js.map +1 -1
- package/dist/skill-test/baseline-integrity.d.ts.map +1 -1
- package/dist/skill-test/baseline-integrity.js +8 -2
- package/dist/skill-test/baseline-integrity.js.map +1 -1
- package/dist/skill-test/build-hook.d.ts +3 -2
- package/dist/skill-test/build-hook.d.ts.map +1 -1
- package/dist/skill-test/build-hook.js +4 -4
- package/dist/skill-test/build-hook.js.map +1 -1
- package/dist/skill-test/declared-env.d.ts +5 -4
- package/dist/skill-test/declared-env.d.ts.map +1 -1
- package/dist/skill-test/declared-env.js +7 -9
- package/dist/skill-test/declared-env.js.map +1 -1
- package/dist/skill-test/eval-executor.d.ts +1 -1
- package/dist/skill-test/eval-executor.js +2 -2
- package/dist/skill-test/eval-executor.js.map +1 -1
- package/dist/skill-test/eval-fragment.d.ts +7 -5
- package/dist/skill-test/eval-fragment.d.ts.map +1 -1
- package/dist/skill-test/eval-fragment.js +13 -7
- package/dist/skill-test/eval-fragment.js.map +1 -1
- package/dist/skill-test/eval-grader.d.ts +56 -4
- package/dist/skill-test/eval-grader.d.ts.map +1 -1
- package/dist/skill-test/eval-grader.js +138 -73
- package/dist/skill-test/eval-grader.js.map +1 -1
- package/dist/skill-test/eval-inputs.d.ts +8 -7
- package/dist/skill-test/eval-inputs.d.ts.map +1 -1
- package/dist/skill-test/eval-inputs.js +9 -11
- package/dist/skill-test/eval-inputs.js.map +1 -1
- package/dist/skill-test/eval-lint.d.ts +4 -4
- package/dist/skill-test/eval-lint.js +5 -5
- package/dist/skill-test/eval-lint.js.map +1 -1
- package/dist/skill-test/eval-suite-isolation.d.ts.map +1 -1
- package/dist/skill-test/eval-suite-isolation.js +8 -5
- package/dist/skill-test/eval-suite-isolation.js.map +1 -1
- package/dist/skill-test/evals-template.d.ts +1 -1
- package/dist/skill-test/evals-template.d.ts.map +1 -1
- package/dist/skill-test/evals-template.js +1 -3
- package/dist/skill-test/evals-template.js.map +1 -1
- package/dist/skill-test/executor-prompt.d.ts +2 -2
- package/dist/skill-test/executor-prompt.js +2 -2
- package/dist/skill-test/failure-reason.d.ts +113 -0
- package/dist/skill-test/failure-reason.d.ts.map +1 -0
- package/dist/skill-test/failure-reason.js +124 -0
- package/dist/skill-test/failure-reason.js.map +1 -0
- package/dist/skill-test/fragment-merge.d.ts +2 -2
- package/dist/skill-test/fragment-merge.js +4 -4
- package/dist/skill-test/grader-prompt.d.ts +2 -2
- package/dist/skill-test/grader-prompt.d.ts.map +1 -1
- package/dist/skill-test/grader-prompt.js +12 -8
- package/dist/skill-test/grader-prompt.js.map +1 -1
- package/dist/skill-test/grading-adapter.d.ts +7 -3
- package/dist/skill-test/grading-adapter.d.ts.map +1 -1
- package/dist/skill-test/grading-adapter.js +10 -9
- package/dist/skill-test/grading-adapter.js.map +1 -1
- package/dist/skill-test/harness-location.d.ts +3 -2
- package/dist/skill-test/harness-location.d.ts.map +1 -1
- package/dist/skill-test/harness-location.js +5 -16
- package/dist/skill-test/harness-location.js.map +1 -1
- package/dist/skill-test/index.d.ts +1 -1
- package/dist/skill-test/index.d.ts.map +1 -1
- package/dist/skill-test/index.js +1 -1
- package/dist/skill-test/index.js.map +1 -1
- package/dist/skill-test/lock.d.ts +11 -10
- package/dist/skill-test/lock.d.ts.map +1 -1
- package/dist/skill-test/lock.js +13 -14
- package/dist/skill-test/lock.js.map +1 -1
- package/dist/skill-test/pipeline.d.ts +3 -1
- package/dist/skill-test/pipeline.d.ts.map +1 -1
- package/dist/skill-test/pipeline.js +5 -1
- package/dist/skill-test/pipeline.js.map +1 -1
- package/dist/skill-test/preflight.d.ts.map +1 -1
- package/dist/skill-test/preflight.js +0 -1
- package/dist/skill-test/preflight.js.map +1 -1
- package/dist/skill-test/prompt-invariants.d.ts +4 -2
- package/dist/skill-test/prompt-invariants.d.ts.map +1 -1
- package/dist/skill-test/prompt-invariants.js +5 -4
- package/dist/skill-test/prompt-invariants.js.map +1 -1
- package/dist/skill-test/run-harness.d.ts +160 -123
- package/dist/skill-test/run-harness.d.ts.map +1 -1
- package/dist/skill-test/run-harness.js +309 -173
- package/dist/skill-test/run-harness.js.map +1 -1
- package/dist/skill-test/staging.d.ts +12 -6
- package/dist/skill-test/staging.d.ts.map +1 -1
- package/dist/skill-test/staging.js +15 -12
- package/dist/skill-test/staging.js.map +1 -1
- package/dist/skill-test/tier-plan.d.ts +1 -1
- package/dist/skill-test/tier-plan.js +1 -1
- package/dist/skill-test/tool-eval-schema.js +2 -2
- package/dist/skill-test/vendor-manifest.d.ts +2 -1
- package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
- package/dist/skill-test/vendor-manifest.js +10 -10
- package/dist/skill-test/vendor-manifest.js.map +1 -1
- package/dist/test-input.d.ts +2 -2
- package/dist/test-input.d.ts.map +1 -1
- package/dist/test-input.js +4 -5
- package/dist/test-input.js.map +1 -1
- package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
- package/dist/validators/agent-instruction-presence.js +0 -1
- package/dist/validators/agent-instruction-presence.js.map +1 -1
- package/dist/validators/bundled-resource-link-detection.d.ts +5 -3
- package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
- package/dist/validators/bundled-resource-link-detection.js +38 -38
- package/dist/validators/bundled-resource-link-detection.js.map +1 -1
- package/dist/validators/format-detection.d.ts +3 -0
- package/dist/validators/format-detection.d.ts.map +1 -1
- package/dist/validators/format-detection.js +41 -26
- package/dist/validators/format-detection.js.map +1 -1
- package/dist/validators/frontmatter-validation.js +1 -1
- package/dist/validators/frontmatter-validation.js.map +1 -1
- package/dist/validators/marketplace-validator.d.ts.map +1 -1
- package/dist/validators/marketplace-validator.js +0 -1
- package/dist/validators/marketplace-validator.js.map +1 -1
- package/dist/validators/mcp-tool-qualification.d.ts +1 -1
- package/dist/validators/mcp-tool-qualification.js +1 -1
- package/dist/validators/packaged-size-limit.d.ts.map +1 -1
- package/dist/validators/packaged-size-limit.js +0 -2
- package/dist/validators/packaged-size-limit.js.map +1 -1
- package/dist/validators/packaging-validator.d.ts +2 -2
- package/dist/validators/packaging-validator.d.ts.map +1 -1
- package/dist/validators/packaging-validator.js +8 -11
- package/dist/validators/packaging-validator.js.map +1 -1
- package/dist/validators/plugin-hosted-shape.d.ts +3 -1
- package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
- package/dist/validators/plugin-hosted-shape.js +5 -6
- package/dist/validators/plugin-hosted-shape.js.map +1 -1
- package/dist/validators/referenced-path-missing.d.ts.map +1 -1
- package/dist/validators/referenced-path-missing.js +0 -1
- package/dist/validators/referenced-path-missing.js.map +1 -1
- package/dist/validators/registry-validator.d.ts.map +1 -1
- package/dist/validators/registry-validator.js +0 -1
- package/dist/validators/registry-validator.js.map +1 -1
- package/dist/validators/rule-engine/index.d.ts +1 -1
- package/dist/validators/rule-engine/rule-context.d.ts +3 -3
- package/dist/validators/rule-engine/rule-context.js +1 -1
- package/dist/validators/rule-engine/rule-engine.d.ts +1 -1
- package/dist/validators/rule-engine/rule-engine.js +5 -5
- package/dist/validators/rule-engine/rule-engine.js.map +1 -1
- package/dist/validators/skill-validator.d.ts.map +1 -1
- package/dist/validators/skill-validator.js +41 -51
- package/dist/validators/skill-validator.js.map +1 -1
- package/dist/validators/source-only-codes.js +1 -1
- package/dist/validators/source-only-codes.js.map +1 -1
- package/dist/validators/types.d.ts +7 -1
- package/dist/validators/types.d.ts.map +1 -1
- package/dist/validators/unified-validator.d.ts.map +1 -1
- package/dist/validators/unified-validator.js +3 -4
- package/dist/validators/unified-validator.js.map +1 -1
- package/dist/validators/validation-rules.d.ts +3 -3
- package/dist/validators/validation-rules.d.ts.map +1 -1
- package/dist/validators/validation-rules.js +9 -38
- package/dist/validators/validation-rules.js.map +1 -1
- package/dist/validators/walker-to-issues.d.ts +2 -0
- package/dist/validators/walker-to-issues.d.ts.map +1 -1
- package/dist/validators/walker-to-issues.js +17 -12
- package/dist/validators/walker-to-issues.js.map +1 -1
- package/dist/walk-link-graph.d.ts +2 -0
- package/dist/walk-link-graph.d.ts.map +1 -1
- package/dist/walk-link-graph.js +20 -8
- package/dist/walk-link-graph.js.map +1 -1
- package/package.json +11 -19
- package/dist/skill-test/exit-codes.d.ts +0 -134
- package/dist/skill-test/exit-codes.d.ts.map +0 -1
- package/dist/skill-test/exit-codes.js +0 -224
- 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
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
102
|
-
*
|
|
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
|
|
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 ?
|
|
110
|
+
return !allPassed && !tolerateEvalFailure ? ExitCode.FINDINGS : ExitCode.OK;
|
|
108
111
|
}
|
|
109
|
-
|
|
112
|
+
function resolveStallMs(opts) {
|
|
110
113
|
return opts.stall === undefined ? undefined : opts.stall * 1000;
|
|
111
114
|
}
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
594
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
1255
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1343
|
-
*
|
|
1344
|
-
* is
|
|
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
|
-
/**
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1735
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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 →
|
|
2386
|
-
//
|
|
2387
|
-
// (R1 no-laundering). The
|
|
2388
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|