@vibe-agent-toolkit/agent-skills 0.1.40 → 0.1.41-rc.2
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/dist/skill-test/eval-executor.d.ts +50 -0
- package/dist/skill-test/eval-executor.d.ts.map +1 -0
- package/dist/skill-test/eval-executor.js +85 -0
- package/dist/skill-test/eval-executor.js.map +1 -0
- package/dist/skill-test/eval-fragment.d.ts +278 -0
- package/dist/skill-test/eval-fragment.d.ts.map +1 -0
- package/dist/skill-test/eval-fragment.js +128 -0
- package/dist/skill-test/eval-fragment.js.map +1 -0
- package/dist/skill-test/eval-grader.d.ts +54 -0
- package/dist/skill-test/eval-grader.d.ts.map +1 -0
- package/dist/skill-test/eval-grader.js +184 -0
- package/dist/skill-test/eval-grader.js.map +1 -0
- package/dist/skill-test/eval-inputs.d.ts +355 -0
- package/dist/skill-test/eval-inputs.d.ts.map +1 -1
- package/dist/skill-test/eval-inputs.js +36 -8
- package/dist/skill-test/eval-inputs.js.map +1 -1
- package/dist/skill-test/eval-lint.d.ts +51 -0
- package/dist/skill-test/eval-lint.d.ts.map +1 -0
- package/dist/skill-test/eval-lint.js +157 -0
- package/dist/skill-test/eval-lint.js.map +1 -0
- package/dist/skill-test/evals-template.d.ts.map +1 -1
- package/dist/skill-test/evals-template.js +7 -0
- package/dist/skill-test/evals-template.js.map +1 -1
- package/dist/skill-test/executor-prompt.d.ts +41 -0
- package/dist/skill-test/executor-prompt.d.ts.map +1 -0
- package/dist/skill-test/executor-prompt.js +63 -0
- package/dist/skill-test/executor-prompt.js.map +1 -0
- package/dist/skill-test/exit-codes.d.ts +8 -5
- package/dist/skill-test/exit-codes.d.ts.map +1 -1
- package/dist/skill-test/exit-codes.js +12 -7
- package/dist/skill-test/exit-codes.js.map +1 -1
- package/dist/skill-test/fragment-merge.d.ts +46 -0
- package/dist/skill-test/fragment-merge.d.ts.map +1 -0
- package/dist/skill-test/fragment-merge.js +77 -0
- package/dist/skill-test/fragment-merge.js.map +1 -0
- package/dist/skill-test/friction-schema.d.ts +10 -10
- package/dist/skill-test/friction-schema.d.ts.map +1 -1
- package/dist/skill-test/friction-schema.js +6 -1
- package/dist/skill-test/friction-schema.js.map +1 -1
- package/dist/skill-test/grader-model.d.ts +6 -0
- package/dist/skill-test/grader-model.d.ts.map +1 -0
- package/dist/skill-test/grader-model.js +6 -0
- package/dist/skill-test/grader-model.js.map +1 -0
- package/dist/skill-test/grader-prompt.d.ts +74 -0
- package/dist/skill-test/grader-prompt.d.ts.map +1 -0
- package/dist/skill-test/grader-prompt.js +173 -0
- package/dist/skill-test/grader-prompt.js.map +1 -0
- package/dist/skill-test/grading-adapter.d.ts +5 -11
- package/dist/skill-test/grading-adapter.d.ts.map +1 -1
- package/dist/skill-test/grading-adapter.js +9 -22
- package/dist/skill-test/grading-adapter.js.map +1 -1
- package/dist/skill-test/grading-schema.d.ts +18 -15
- package/dist/skill-test/grading-schema.d.ts.map +1 -1
- package/dist/skill-test/grading-schema.js +6 -5
- package/dist/skill-test/grading-schema.js.map +1 -1
- package/dist/skill-test/index.d.ts +13 -3
- package/dist/skill-test/index.d.ts.map +1 -1
- package/dist/skill-test/index.js +13 -3
- package/dist/skill-test/index.js.map +1 -1
- package/dist/skill-test/pipeline.d.ts +29 -0
- package/dist/skill-test/pipeline.d.ts.map +1 -0
- package/dist/skill-test/pipeline.js +77 -0
- package/dist/skill-test/pipeline.js.map +1 -0
- package/dist/skill-test/prompt-invariants.d.ts +27 -0
- package/dist/skill-test/prompt-invariants.d.ts.map +1 -0
- package/dist/skill-test/prompt-invariants.js +38 -0
- package/dist/skill-test/prompt-invariants.js.map +1 -0
- package/dist/skill-test/run-harness.d.ts +187 -55
- package/dist/skill-test/run-harness.d.ts.map +1 -1
- package/dist/skill-test/run-harness.js +539 -189
- package/dist/skill-test/run-harness.js.map +1 -1
- package/dist/skill-test/tier-plan.d.ts +89 -0
- package/dist/skill-test/tier-plan.d.ts.map +1 -0
- package/dist/skill-test/tier-plan.js +96 -0
- package/dist/skill-test/tier-plan.js.map +1 -0
- package/dist/skill-test/tool-eval-schema.d.ts +412 -0
- package/dist/skill-test/tool-eval-schema.d.ts.map +1 -0
- package/dist/skill-test/tool-eval-schema.js +83 -0
- package/dist/skill-test/tool-eval-schema.js.map +1 -0
- package/dist/validators/packaging-validator.d.ts +8 -1
- package/dist/validators/packaging-validator.d.ts.map +1 -1
- package/dist/validators/packaging-validator.js.map +1 -1
- package/package.json +5 -5
- package/schemas/friction-report.json +2 -1
- package/dist/skill-test/experimenter-prompt.d.ts +0 -42
- package/dist/skill-test/experimenter-prompt.d.ts.map +0 -1
- package/dist/skill-test/experimenter-prompt.js +0 -104
- package/dist/skill-test/experimenter-prompt.js.map +0 -1
|
@@ -5,27 +5,37 @@
|
|
|
5
5
|
* lock → assert safe workdir + harness root → stage → resolve staged-subject
|
|
6
6
|
* eval path → bootstrap-check (scaffold template + exit 3 if absent)
|
|
7
7
|
* → preflight (return early exitCode 2 on failure) → ack enforcement
|
|
8
|
-
* → build
|
|
9
|
-
* →
|
|
8
|
+
* → dry-run short-circuit → build per-eval work items → run the vat-owned
|
|
9
|
+
* executor→grader pipeline (bounded-parallel) → merge grader fragments →
|
|
10
|
+
* write grading.json/friction.json (vat is SOLE writer) → reconcile verdict
|
|
11
|
+
* → release lock → return result
|
|
10
12
|
*/
|
|
11
13
|
import { randomBytes } from 'node:crypto';
|
|
12
14
|
import { cpSync, existsSync, lstatSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
15
|
import { basename, dirname } from 'node:path';
|
|
14
16
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { getToolVersion, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, resolveAssetReference, safeExecResult, safePath,
|
|
17
|
+
import { getToolVersion, killAllActiveClaudeChildren, mkdirSyncReal, normalizedTmpdir, probeAuthStatus, resolveAssetReference, safeExecResult, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
|
|
16
18
|
import { resolveSkillSource } from '../skill-source/resolve-skill-source.js';
|
|
17
19
|
import { assembleChildEnv, computeEnvTokens, resolveInjectEnv } from './declared-env.js';
|
|
20
|
+
import { runExecutorForEval } from './eval-executor.js';
|
|
21
|
+
import { runGraderForEval } from './eval-grader.js';
|
|
18
22
|
import { EvalInputError, parseEvalSuite, stageEvalWorkspaces } from './eval-inputs.js';
|
|
23
|
+
import { lintEvalExpectations, lintToolExpectationExecutables } from './eval-lint.js';
|
|
19
24
|
import { writeEvalsTemplate } from './evals-template.js';
|
|
20
25
|
import { BootstrapNeededError, InternalHarnessError, SkillTestExitCode, } from './exit-codes.js';
|
|
21
|
-
import {
|
|
26
|
+
import { mergeFragmentsToFriction, mergeFragmentsToGrading, mergeFragmentsToToolEval } from './fragment-merge.js';
|
|
22
27
|
import { FrictionReportSchema } from './friction-schema.js';
|
|
23
|
-
import {
|
|
28
|
+
import { DEFAULT_CONCURRENCY, DEFAULT_GRADER_MODEL } from './grader-model.js';
|
|
29
|
+
import { reconcileGrading } from './grading-adapter.js';
|
|
30
|
+
import { GradingReportSchema } from './grading-schema.js';
|
|
24
31
|
import { assertSafeHarnessRoot, assertSafeWorkdir, prepareHarnessRoot, resolveHarnessRoot } from './harness-location.js';
|
|
25
32
|
import { acquireHarnessLock, installSignalCleanup } from './lock.js';
|
|
33
|
+
import { runPipeline } from './pipeline.js';
|
|
26
34
|
import { detectPluginLayout } from './plugin-layout.js';
|
|
27
35
|
import { runPreflight } from './preflight.js';
|
|
28
36
|
import { descriptorToSource, stageHarness } from './staging.js';
|
|
37
|
+
import { buildSkippedSummary, formatSkippedTiersSummary, groupEvalsByTier, shouldGateAfterTier, } from './tier-plan.js';
|
|
38
|
+
import { ToolEvalReportSchema } from './tool-eval-schema.js';
|
|
29
39
|
import { verifyVendoredManifest } from './vendor-manifest.js';
|
|
30
40
|
/** Default subpath of the subject's eval suite, relative to its source dir. */
|
|
31
41
|
const DEFAULT_EVALS_SUBPATH = 'evals/evals.json';
|
|
@@ -37,6 +47,13 @@ const DEFAULT_EVALS_SUBPATH = 'evals/evals.json';
|
|
|
37
47
|
* shipped alongside `dist/`. Its hash manifest is verified during preflight.
|
|
38
48
|
*/
|
|
39
49
|
const VENDORED_SKILL_CREATOR_DIR = safePath.resolve(dirname(fileURLToPath(import.meta.url)), '../../vendor/skill-creator');
|
|
50
|
+
/**
|
|
51
|
+
* Absolute path to the vendored skill-creator grader rubric each per-eval grader
|
|
52
|
+
* spawn is told to judge against (issue #145). Lives inside
|
|
53
|
+
* {@link VENDORED_SKILL_CREATOR_DIR}, whose hash manifest is verified during
|
|
54
|
+
* preflight — so the rubric the grader uses is the pinned, integrity-checked copy.
|
|
55
|
+
*/
|
|
56
|
+
const GRADER_RUBRIC_PATH = safePath.join(VENDORED_SKILL_CREATOR_DIR, 'agents/grader.md');
|
|
40
57
|
// ---------------------------------------------------------------------------
|
|
41
58
|
// Internal helpers
|
|
42
59
|
// ---------------------------------------------------------------------------
|
|
@@ -44,8 +61,8 @@ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
44
61
|
const DEFAULT_MAX_TURNS = 50;
|
|
45
62
|
const DEFAULT_MAX_BUDGET_USD = 5;
|
|
46
63
|
/**
|
|
47
|
-
* Built-in cost/runtime safety ceilings
|
|
48
|
-
* SAME values the harness applies as defaults, but exported as an explicit cap so
|
|
64
|
+
* Built-in cost/runtime safety ceilings applied PER executor/grader spawn. These
|
|
65
|
+
* are the SAME values the harness applies as defaults, but exported as an explicit cap so
|
|
49
66
|
* the CLI precedence layer (run.ts) can enforce a critical asymmetry:
|
|
50
67
|
*
|
|
51
68
|
* - a CLI flag (explicit operator intent, typed at the terminal for THIS run)
|
|
@@ -64,26 +81,14 @@ export const SKILL_TEST_BUILTIN_CAPS = {
|
|
|
64
81
|
timeoutSeconds: DEFAULT_TIMEOUT_MS / 1000,
|
|
65
82
|
};
|
|
66
83
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* unbounded budget). An EXPLICIT `--timeout` always bypasses this (see resolveTimeoutMs).
|
|
73
|
-
*/
|
|
74
|
-
export function computeDefaultTimeoutMs(declaredEvalCount) {
|
|
75
|
-
return Math.max(300_000, Math.min(3_600_000, 120_000 + declaredEvalCount * 120_000));
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Resolve the effective wall-clock timeout (ms). An explicit `--timeout` (seconds)
|
|
79
|
-
* ALWAYS wins, unchanged. Otherwise the default scales with the declared eval count
|
|
80
|
-
* via {@link computeDefaultTimeoutMs}; when that count is unknown/unparseable at the
|
|
81
|
-
* call site, fall back to the flat 300s default.
|
|
84
|
+
* Resolve the effective PER-EVAL wall-clock timeout (ms). Each executor and
|
|
85
|
+
* grader spawn is an independent, bounded-parallel unit (issue #145), so the
|
|
86
|
+
* budget is a flat per-spawn ceiling — NOT scaled by the suite size the way the
|
|
87
|
+
* old single serial run's budget was. An explicit `--timeout` (seconds)
|
|
88
|
+
* wins; otherwise the flat {@link DEFAULT_TIMEOUT_MS} default applies.
|
|
82
89
|
*/
|
|
83
|
-
export function resolveTimeoutMs(opts
|
|
84
|
-
|
|
85
|
-
return opts.timeout * 1000;
|
|
86
|
-
return declaredEvalCount === undefined ? DEFAULT_TIMEOUT_MS : computeDefaultTimeoutMs(declaredEvalCount);
|
|
90
|
+
export function resolveTimeoutMs(opts) {
|
|
91
|
+
return opts.timeout === undefined ? DEFAULT_TIMEOUT_MS : opts.timeout * 1000;
|
|
87
92
|
}
|
|
88
93
|
/**
|
|
89
94
|
* Map an eval verdict to a process exit code. Default behavior (fail-closed): a
|
|
@@ -231,8 +236,8 @@ export function buildPreflightInput(evalsPath, pluginDirs, opts, knobs) {
|
|
|
231
236
|
if (opts.allowUnverifiedSkillSource === true)
|
|
232
237
|
return true;
|
|
233
238
|
// Verify the committed, pinned vendored skill-creator copy that ships with
|
|
234
|
-
// this package — the
|
|
235
|
-
// to
|
|
239
|
+
// this package — the grader rubric (agents/grader.md) each grader spawn is
|
|
240
|
+
// told to judge against. A missing, unparseable, or mutated manifest fails preflight (exit 2).
|
|
236
241
|
return verifyVendoredManifest(VENDORED_SKILL_CREATOR_DIR);
|
|
237
242
|
},
|
|
238
243
|
costEstimate,
|
|
@@ -259,44 +264,6 @@ export function renderPreflightSummary(checks) {
|
|
|
259
264
|
export function isAcknowledged(opts) {
|
|
260
265
|
return opts.dryRun === true || opts.acknowledgedRunsSkillCode === true;
|
|
261
266
|
}
|
|
262
|
-
/**
|
|
263
|
-
* Format the experimenter-timeout message. Pure so it can be unit-tested. Always
|
|
264
|
-
* reports the raw budget (ms + a minute label). When the declared eval count is
|
|
265
|
-
* known it appends the count and a hint that the default budget scales with eval
|
|
266
|
-
* count (raise `--timeout`/`--stall` for larger suites); when a best-effort
|
|
267
|
-
* completed count is also available it notes `completed ~N/declared`. With no
|
|
268
|
-
* declared count it stays honest and omits those clauses.
|
|
269
|
-
*/
|
|
270
|
-
export function formatTimeoutMessage(input) {
|
|
271
|
-
const minutes = input.timeoutMs / 60_000;
|
|
272
|
-
const minutesLabel = Number.isInteger(minutes) ? `${minutes}m` : `${minutes.toFixed(1)}m`;
|
|
273
|
-
let msg = `Experimenter timed out after ${input.timeoutMs}ms (${minutesLabel}).`;
|
|
274
|
-
if (input.declaredEvalCount !== undefined) {
|
|
275
|
-
const completed = input.completedEvalCount === undefined
|
|
276
|
-
? ''
|
|
277
|
-
: ` (completed ~${input.completedEvalCount}/${input.declaredEvalCount})`;
|
|
278
|
-
msg +=
|
|
279
|
-
` Declared evals: ${input.declaredEvalCount}${completed}; the default budget scales with eval count` +
|
|
280
|
-
' — raise --timeout (and --stall) for larger suites.';
|
|
281
|
-
}
|
|
282
|
-
return msg;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Translate a non-success spawn outcome into an InternalHarnessError (exit 1).
|
|
286
|
-
* A stall, a timeout, OR a non-zero exit are each authoritative — a non-zero exit
|
|
287
|
-
* is never laundered into a PASS even if a grading.json happens to be on disk.
|
|
288
|
-
*/
|
|
289
|
-
export function assertExperimenterSucceeded(spawnResult, stallMs, timeoutMs, timeoutContext) {
|
|
290
|
-
if (spawnResult.stalled) {
|
|
291
|
-
throw new InternalHarnessError(`Experimenter stalled (no output for ${stallMs ?? 0}ms).`);
|
|
292
|
-
}
|
|
293
|
-
if (spawnResult.timedOut) {
|
|
294
|
-
throw new InternalHarnessError(formatTimeoutMessage({ timeoutMs, ...timeoutContext }));
|
|
295
|
-
}
|
|
296
|
-
if (spawnResult.status !== 0) {
|
|
297
|
-
throw new InternalHarnessError(`Experimenter exited non-zero (status ${spawnResult.status}).`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
267
|
/**
|
|
301
268
|
* Format a friction report for human consumption — one line per entry as
|
|
302
269
|
* `[<severity>] <category>: <message>`. Pure; returns the empty string for no
|
|
@@ -307,8 +274,8 @@ export function formatFrictionReport(items) {
|
|
|
307
274
|
}
|
|
308
275
|
/**
|
|
309
276
|
* Read the run's friction.json (if present + valid) and echo a concise report to
|
|
310
|
-
* STDERR so users don't miss packaging-fidelity friction the
|
|
311
|
-
* (it is otherwise only written to disk). Best-effort: a missing, unparseable, or
|
|
277
|
+
* STDERR so users don't miss packaging-fidelity friction VAT merged from the grader
|
|
278
|
+
* fragments (it is otherwise only written to disk). Best-effort: a missing, unparseable, or
|
|
312
279
|
* empty report emits nothing. Never touches stdout (which stays machine-readable).
|
|
313
280
|
* Accepts `undefined` (a no-op) so the harness `finally` can call it unconditionally
|
|
314
281
|
* even when a throw preempted assignment of the friction path.
|
|
@@ -398,11 +365,11 @@ function overlayAuthoredEvalSuite(opts, subjectStagedDir, evalsSubpath) {
|
|
|
398
365
|
}
|
|
399
366
|
}
|
|
400
367
|
/** Parse the staged eval suite and materialize each eval's input `files` into
|
|
401
|
-
* `<harnessRoot>/workspaces/<id>/`. Returns the workspaces root
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* leak a prior run's inputs. Throws {@link EvalInputError}
|
|
405
|
-
* exit 2) on a bad suite or a missing input file. */
|
|
368
|
+
* `<harnessRoot>/workspaces/<id>/`. Returns the workspaces root, the parsed
|
|
369
|
+
* {@link EvalSuite} (so the eval loop has the entries without re-reading), and the
|
|
370
|
+
* declared eval count (derived from the suite). The dir is wiped first so a reused
|
|
371
|
+
* harness root cannot leak a prior run's inputs. Throws {@link EvalInputError}
|
|
372
|
+
* (mapped by the caller to exit 2) on a bad suite or a missing input file. */
|
|
406
373
|
export function stageWorkspacesForRun(evalsPath, harnessRoot) {
|
|
407
374
|
const workspacesRoot = safePath.joinUnderRoot(harnessRoot, 'workspaces');
|
|
408
375
|
rmSync(workspacesRoot, { recursive: true, force: true });
|
|
@@ -412,6 +379,7 @@ export function stageWorkspacesForRun(evalsPath, harnessRoot) {
|
|
|
412
379
|
return {
|
|
413
380
|
workspacesRoot: stageEvalWorkspaces({ suite, evalsDir: dirname(evalsPath), workspacesRoot }),
|
|
414
381
|
declaredEvalCount: suite.evals.length,
|
|
382
|
+
suite,
|
|
415
383
|
};
|
|
416
384
|
}
|
|
417
385
|
/** Format the model flag string for logging and dry-run summary output. */
|
|
@@ -442,18 +410,32 @@ function attemptStageWorkspaces(evalsPath, harnessRoot) {
|
|
|
442
410
|
}
|
|
443
411
|
}
|
|
444
412
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* process.env, which would hand untrusted skill code every secret it contains.
|
|
413
|
+
* Advisory (never-fatal) lint pass over the parsed suite: nudges authors away
|
|
414
|
+
* from presence-only expectations ("mentions/includes …") that a hallucinated
|
|
415
|
+
* or wrong-for-the-right-reason answer could still satisfy (issue #145
|
|
416
|
+
* follow-up). Purely additive stderr noise, emitted before any spawn — never
|
|
417
|
+
* affects exitCode. Extracted so the Step 5.5 call site stays a single line
|
|
418
|
+
* (keeps the orchestrator's cognitive complexity budget).
|
|
452
419
|
*/
|
|
453
|
-
function
|
|
454
|
-
|
|
455
|
-
|
|
420
|
+
function emitEvalLintWarnings(evals, declaredExecutableNames) {
|
|
421
|
+
for (const lintWarning of lintEvalExpectations(evals)) {
|
|
422
|
+
process.stderr.write(`warning: ${lintWarning.message}\n`);
|
|
423
|
+
}
|
|
424
|
+
// Undeclared-executable lint (adopter follow-up): a toolExpectation naming a
|
|
425
|
+
// typo of a declared executable would silently never match — flag it before spend.
|
|
426
|
+
for (const lintWarning of lintToolExpectationExecutables(evals, declaredExecutableNames)) {
|
|
427
|
+
process.stderr.write(`warning: ${lintWarning.message}\n`);
|
|
456
428
|
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Assemble the EXECUTOR's child env: the scrubbed forwarded env unioned with the
|
|
432
|
+
* declared test env (Features A + B) and CLAUDE_PLUGIN_ROOT, then emit the
|
|
433
|
+
* transparency line and any protected-key collision warnings to stderr. The
|
|
434
|
+
* caller guards `resolvedAuth` non-null (a null after a passed preflight is an
|
|
435
|
+
* internal invariant violation), never falling back to process.env — which would
|
|
436
|
+
* hand untrusted skill code every secret it contains.
|
|
437
|
+
*/
|
|
438
|
+
function resolveDeclaredChildEnv(input) {
|
|
457
439
|
const envTokens = computeEnvTokens({
|
|
458
440
|
subjectStagedDir: input.subjectStagedDir,
|
|
459
441
|
harnessRoot: input.harnessRoot,
|
|
@@ -501,7 +483,8 @@ export function buildDryRunSummary(input) {
|
|
|
501
483
|
lines.push('[dry-run] A real run would: stage the source dir as-is, then spawn claude.');
|
|
502
484
|
}
|
|
503
485
|
const count = input.provenanceEntryCount;
|
|
504
|
-
lines.push(`[dry-run] Would
|
|
486
|
+
lines.push(`[dry-run] Would run ${input.evalCount} eval${input.evalCount === 1 ? '' : 's'} as executor→grader spawn ` +
|
|
487
|
+
`pair${input.evalCount === 1 ? '' : 's'} at concurrency ${input.concurrency}.`, `[dry-run] Executor ${input.modelFlag}; grader model ${input.graderModel} (prompt via stdin).`, `[dry-run] Staged manifest: ${count} entr${count === 1 ? 'y' : 'ies'} | fingerprint: ${input.provenanceFingerprint}`, `[dry-run] Provenance: ${input.provenancePath}`);
|
|
505
488
|
return lines.join('\n');
|
|
506
489
|
}
|
|
507
490
|
/**
|
|
@@ -535,6 +518,343 @@ export function cleanupHarness(harnessRoot, opts) {
|
|
|
535
518
|
// Swallow: a failed cleanup is not a run failure.
|
|
536
519
|
}
|
|
537
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Build the per-eval work items for a set of evals (one tier's worth, or a whole
|
|
523
|
+
* suite). Every eval gets a WITH arm (skill present). When `baseline` is set,
|
|
524
|
+
* every eval ALSO gets a WITHOUT arm (skill absent) so vat can record an
|
|
525
|
+
* informational A/B — the WITHOUT arm never contributes to the pass/fail verdict
|
|
526
|
+
* (see {@link partitionFragmentsByArm}) and never drives tier gating. Pure +
|
|
527
|
+
* unit-testable.
|
|
528
|
+
*/
|
|
529
|
+
export function buildEvalWorkItems(evals, baseline) {
|
|
530
|
+
const items = [];
|
|
531
|
+
for (const entry of evals) {
|
|
532
|
+
items.push({ entry, arm: 'with' });
|
|
533
|
+
if (baseline)
|
|
534
|
+
items.push({ entry, arm: 'without' });
|
|
535
|
+
}
|
|
536
|
+
return items;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Partition graded fragments into the WITH arm (the authoritative verdict +
|
|
540
|
+
* grading.json) and the WITHOUT arm (baseline.json, informational only). A
|
|
541
|
+
* fragment with no `arm` (or `arm: 'with'`) is a WITH-arm fragment. Pure +
|
|
542
|
+
* unit-testable.
|
|
543
|
+
*/
|
|
544
|
+
export function partitionFragmentsByArm(fragments) {
|
|
545
|
+
const withArm = [];
|
|
546
|
+
const withoutArm = [];
|
|
547
|
+
for (const fragment of fragments) {
|
|
548
|
+
if (fragment.arm === 'without')
|
|
549
|
+
withoutArm.push(fragment);
|
|
550
|
+
else
|
|
551
|
+
withArm.push(fragment);
|
|
552
|
+
}
|
|
553
|
+
return { withArm, withoutArm };
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The vat-only grader dir for a run: `<tmp>/vat-skill-grade-<dirToken>/`. It is
|
|
557
|
+
* deliberately OUTSIDE the harness root (the skill's `--add-dir` sandbox) and
|
|
558
|
+
* created 0700, so it is invisible to Claude's own permission model and to any
|
|
559
|
+
* OTHER OS user. Pure (derives a path only).
|
|
560
|
+
*
|
|
561
|
+
* SCOPE OF THE GUARANTEE — read honestly. `--add-dir`/`bypassPermissions` is
|
|
562
|
+
* Claude's permission model, NOT an OS sandbox: the executor's skill code runs
|
|
563
|
+
* as the SAME OS uid as vat, so it CAN read a 0700 dir this process owns. The
|
|
564
|
+
* layered defenses here — dir outside the sandbox, named by an unpredictable
|
|
565
|
+
* `dirToken` (distinct from the integrity nonce, which never touches the dir
|
|
566
|
+
* name or any argv and travels only via grader stdin), the nonce echoed back
|
|
567
|
+
* per fragment, and each fragment file unlinked the instant vat reads it — RAISE
|
|
568
|
+
* THE BAR against same-uid forgery (a forger must now win a per-fragment
|
|
569
|
+
* read→overwrite race against a secret it cannot predict, with no persisted copy
|
|
570
|
+
* to harvest at leisure). They do NOT amount to true isolation from same-uid
|
|
571
|
+
* code. The complete fix is running the grader under a SEPARATE OS uid /
|
|
572
|
+
* container; that is tracked as a follow-up (see CHANGELOG "Security" notes) and
|
|
573
|
+
* is the only thing that closes the residual race outright.
|
|
574
|
+
*/
|
|
575
|
+
export function resolveGraderOutDir(dirToken) {
|
|
576
|
+
return safePath.join(normalizedTmpdir(), `vat-skill-grade-${dirToken}`);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The executor working directory for one eval: its staged input workspace
|
|
580
|
+
* `<workspacesRoot>/<id>` when the eval declares input `files`, else undefined
|
|
581
|
+
* (the executor then defaults to the staged subject dir). Pure + unit-testable.
|
|
582
|
+
*/
|
|
583
|
+
export function resolvePerEvalWorkspaceDir(entry, workspacesRoot) {
|
|
584
|
+
if (entry.files === undefined || entry.files.length === 0)
|
|
585
|
+
return undefined;
|
|
586
|
+
return safePath.joinUnderRoot(workspacesRoot, String(entry.id));
|
|
587
|
+
}
|
|
588
|
+
/** Best-effort removal of the vat-only grader dir (never throws — runs from cleanup). */
|
|
589
|
+
export function removeGraderOutDir(graderOutDir) {
|
|
590
|
+
if (graderOutDir === undefined)
|
|
591
|
+
return;
|
|
592
|
+
try {
|
|
593
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived tmp grader dir
|
|
594
|
+
if (!existsSync(graderOutDir))
|
|
595
|
+
return;
|
|
596
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived tmp grader dir
|
|
597
|
+
if (lstatSync(graderOutDir).isSymbolicLink())
|
|
598
|
+
return;
|
|
599
|
+
rmSync(graderOutDir, { recursive: true, force: true });
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// Swallow: a failed grader-dir cleanup is not a run failure.
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Run ONE work item: the blind executor spawn, then the grader spawn over its
|
|
607
|
+
* captured transcript (issue #145). Returns the grader fragment tagged with the
|
|
608
|
+
* item's arm. The WITHOUT arm runs the executor with `pluginDirs: []` (skill
|
|
609
|
+
* absent). Grader fragments are written under a PER-ARM subdir of the vat-only
|
|
610
|
+
* grader dir so a WITH and WITHOUT run of the same eval id cannot collide.
|
|
611
|
+
*
|
|
612
|
+
* Throws propagate: an executor/grader {@link InternalHarnessError} (timeout,
|
|
613
|
+
* stall, spawn error, grader failure/missing-fragment) fails the whole run
|
|
614
|
+
* (exit 1); a RateLimitSignal is retried by the pipeline. An executor CLEAN
|
|
615
|
+
* failure is NOT thrown — its transcript flows into the grader, whose failing
|
|
616
|
+
* fragment surfaces as an eval failure (exit 4 via the verdict), never exit 1.
|
|
617
|
+
*/
|
|
618
|
+
async function runEvalWorker(item, ctx) {
|
|
619
|
+
const evalId = String(item.entry.id);
|
|
620
|
+
const workspaceDir = resolvePerEvalWorkspaceDir(item.entry, ctx.workspacesRoot);
|
|
621
|
+
const onProgress = (chunk) => { process.stderr.write(chunk); };
|
|
622
|
+
const outcome = await runExecutorForEval({
|
|
623
|
+
evalId,
|
|
624
|
+
task: item.entry.prompt,
|
|
625
|
+
subjectStagedDir: ctx.subjectStagedDir,
|
|
626
|
+
...(workspaceDir === undefined ? {} : { workspaceDir }),
|
|
627
|
+
pluginDirs: item.arm === 'without' ? [] : ctx.pluginDirs,
|
|
628
|
+
env: ctx.executorEnv,
|
|
629
|
+
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
|
630
|
+
maxTurns: ctx.maxTurns,
|
|
631
|
+
maxBudgetUsd: ctx.maxBudgetUsd,
|
|
632
|
+
timeoutMs: ctx.timeoutMs,
|
|
633
|
+
...(ctx.stallMs === undefined ? {} : { stallMs: ctx.stallMs }),
|
|
634
|
+
...(ctx.spawn === undefined ? {} : { spawn: ctx.spawn }),
|
|
635
|
+
onProgress,
|
|
636
|
+
});
|
|
637
|
+
recordSessionCost(ctx.costAccumulator, outcome.parsed.result?.totalCostUsd);
|
|
638
|
+
// Tool expectations are about the SKILL's tools, so they ride the WITH arm ONLY —
|
|
639
|
+
// the WITHOUT (skill-absent) arm has no skill, hence nothing to judge tools against.
|
|
640
|
+
const isWithArm = item.arm === 'with';
|
|
641
|
+
const fragment = await runGraderForEval({
|
|
642
|
+
evalId,
|
|
643
|
+
transcript: outcome.transcript,
|
|
644
|
+
expectations: item.entry.expectations,
|
|
645
|
+
...(item.entry.expected_output === undefined ? {} : { expectedOutput: item.entry.expected_output }),
|
|
646
|
+
...(isWithArm && item.entry.toolExpectations !== undefined
|
|
647
|
+
? { toolExpectations: item.entry.toolExpectations }
|
|
648
|
+
: {}),
|
|
649
|
+
...(isWithArm && ctx.declaredExecutables !== undefined
|
|
650
|
+
? { declaredExecutables: ctx.declaredExecutables }
|
|
651
|
+
: {}),
|
|
652
|
+
rubricPath: GRADER_RUBRIC_PATH,
|
|
653
|
+
graderOutDir: safePath.joinUnderRoot(ctx.graderOutDir, item.arm),
|
|
654
|
+
graderModel: ctx.graderModel,
|
|
655
|
+
nonce: ctx.runNonce,
|
|
656
|
+
maxTurns: ctx.maxTurns,
|
|
657
|
+
maxBudgetUsd: ctx.maxBudgetUsd,
|
|
658
|
+
timeoutMs: ctx.timeoutMs,
|
|
659
|
+
...(ctx.stallMs === undefined ? {} : { stallMs: ctx.stallMs }),
|
|
660
|
+
env: ctx.graderEnv,
|
|
661
|
+
...(ctx.spawn === undefined ? {} : { spawn: ctx.spawn }),
|
|
662
|
+
onProgress,
|
|
663
|
+
costSink: (usd) => recordSessionCost(ctx.costAccumulator, usd),
|
|
664
|
+
});
|
|
665
|
+
return { ...fragment, arm: item.arm };
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Run evals TIER by TIER (ascending), bounded-parallel WITHIN each tier, with a
|
|
669
|
+
* GATE between tiers (issue #145 Phase G). After a tier completes, apply the
|
|
670
|
+
* default gate policy ({@link shouldGateAfterTier}) over that tier's WITH-arm
|
|
671
|
+
* fragments: if any eval in the tier did not fully pass, do NOT launch the higher
|
|
672
|
+
* (more expensive) tiers — their evals are recorded as SKIPPED (a distinct state,
|
|
673
|
+
* never counted as passed) so the run stops spending once a cheaper tier already
|
|
674
|
+
* failed. In-flight policy: the tier's own running evals finish (awaited by
|
|
675
|
+
* `runTier`) BEFORE the gate is checked, so we never launch the next tier once we
|
|
676
|
+
* decide to stop. The WITHOUT (baseline) arm rides alongside its WITH arm but does
|
|
677
|
+
* not drive the gate — gating is about the WITH-arm skill behavior only.
|
|
678
|
+
*/
|
|
679
|
+
async function runEvalsTiered(input) {
|
|
680
|
+
const groups = groupEvalsByTier(input.evals);
|
|
681
|
+
const fragments = [];
|
|
682
|
+
for (const [index, group] of groups.entries()) {
|
|
683
|
+
const tierFragments = await input.runTier(buildEvalWorkItems(group.evals, input.baseline));
|
|
684
|
+
fragments.push(...tierFragments);
|
|
685
|
+
const withArm = tierFragments.filter((f) => f.arm !== 'without');
|
|
686
|
+
if (!shouldGateAfterTier(withArm))
|
|
687
|
+
continue;
|
|
688
|
+
const remaining = groups.slice(index + 1);
|
|
689
|
+
if (remaining.length === 0)
|
|
690
|
+
break;
|
|
691
|
+
const skipped = buildSkippedSummary(group.tier, remaining);
|
|
692
|
+
// Legibility (required): name the skipped tiers on stderr so a fail-fast run is
|
|
693
|
+
// never mistaken for a smaller passing suite. stdout stays machine-readable.
|
|
694
|
+
process.stderr.write(formatSkippedTiersSummary(skipped) + '\n');
|
|
695
|
+
return { fragments, skipped };
|
|
696
|
+
}
|
|
697
|
+
return { fragments };
|
|
698
|
+
}
|
|
699
|
+
/** Resolve the run's grading/friction/baseline/tool-eval artifact paths under
|
|
700
|
+
* `resultsDir`. Single source of truth for the filenames (used by the
|
|
701
|
+
* pre-pipeline stale wipe AND the post-merge writer). */
|
|
702
|
+
export function resolveArtifactPaths(resultsDir) {
|
|
703
|
+
return {
|
|
704
|
+
gradingOut: safePath.join(resultsDir, 'grading.json'),
|
|
705
|
+
frictionOut: safePath.join(resultsDir, 'friction.json'),
|
|
706
|
+
baselineOut: safePath.join(resultsDir, 'baseline.json'),
|
|
707
|
+
toolEvalOut: safePath.join(resultsDir, 'tool-eval.json'),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Remove any PRIOR run's artifacts before this run writes its own. The harness
|
|
712
|
+
* root is deterministic per skill-set and reused across runs (`--keep`/`--out`, or
|
|
713
|
+
* after a crash/SIGKILL that preempted cleanup), so a stale grading/friction/
|
|
714
|
+
* baseline/tool-eval.json can otherwise survive into a run that throws BEFORE the merge —
|
|
715
|
+
* where the `finally` would then echo the PRIOR run's friction as if it were this
|
|
716
|
+
* run's. Wiping all three up front closes that cross-run leak. Best-effort
|
|
717
|
+
* (`force: true`) — a missing file is fine.
|
|
718
|
+
*/
|
|
719
|
+
export function wipeStaleArtifacts(paths) {
|
|
720
|
+
rmSync(paths.gradingOut, { force: true });
|
|
721
|
+
rmSync(paths.frictionOut, { force: true });
|
|
722
|
+
rmSync(paths.baselineOut, { force: true });
|
|
723
|
+
rmSync(paths.toolEvalOut, { force: true });
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Merge the run's grader fragments and WRITE the run artifacts (vat is the SOLE
|
|
727
|
+
* writer): grading.json from the WITH-arm fragments, friction.json from ALL
|
|
728
|
+
* fragments, tool-eval.json from the WITH-arm fragments' `tool` verdicts (a
|
|
729
|
+
* SEPARATE channel — C2; the WITHOUT arm carries no toolExpectations), and — only
|
|
730
|
+
* when a WITHOUT arm ran (baseline) — baseline.json from the WITHOUT-arm fragments
|
|
731
|
+
* (informational A/B, never part of the verdict). Stale copies from a reused harness
|
|
732
|
+
* were already removed by {@link wipeStaleArtifacts} pre-pipeline. Returns the
|
|
733
|
+
* reconciled prose-expectation verdict AND the merged tool-eval report so the caller
|
|
734
|
+
* can compute the COMPOSITE verdict. Every fragment's per-run nonce is re-verified
|
|
735
|
+
* inside {@link mergeFragmentsToGrading}.
|
|
736
|
+
*/
|
|
737
|
+
function writeRunArtifactsAndReconcile(fragments, runNonce, paths) {
|
|
738
|
+
const { withArm, withoutArm } = partitionFragmentsByArm(fragments);
|
|
739
|
+
const grading = mergeFragmentsToGrading(withArm, runNonce);
|
|
740
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
741
|
+
writeFileSync(paths.gradingOut, JSON.stringify(grading, null, 2) + '\n', 'utf-8');
|
|
742
|
+
const friction = mergeFragmentsToFriction(fragments);
|
|
743
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
744
|
+
writeFileSync(paths.frictionOut, JSON.stringify(friction, null, 2) + '\n', 'utf-8');
|
|
745
|
+
// Tool verdicts come from the WITH arm ONLY — the WITHOUT/skill-absent arm never
|
|
746
|
+
// carries toolExpectations, so its fragments have no `tool` body to merge.
|
|
747
|
+
const toolEval = mergeFragmentsToToolEval(withArm);
|
|
748
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
749
|
+
writeFileSync(paths.toolEvalOut, JSON.stringify(toolEval, null, 2) + '\n', 'utf-8');
|
|
750
|
+
if (withoutArm.length > 0) {
|
|
751
|
+
const baseline = mergeFragmentsToGrading(withoutArm, runNonce);
|
|
752
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
753
|
+
writeFileSync(paths.baselineOut, JSON.stringify(baseline, null, 2) + '\n', 'utf-8');
|
|
754
|
+
}
|
|
755
|
+
return { verdict: reconcileGrading(grading), toolEval };
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The COMPOSITE run verdict (issue #145 Phase T): the run passes only when BOTH
|
|
759
|
+
* the prose-expectation grading passed AND every tool-expectation verdict passed.
|
|
760
|
+
* Tool verdicts live in tool-eval.json (a SEPARATE channel — C2); this combines
|
|
761
|
+
* the two at the exit-code layer WITHOUT mixing the channels' data. When no eval
|
|
762
|
+
* declared `toolExpectations`, `toolEval.evals` is empty and this equals
|
|
763
|
+
* `outputAllPassed`. Pure + unit-testable.
|
|
764
|
+
*/
|
|
765
|
+
export function computeCompositeVerdict(outputAllPassed, toolEval) {
|
|
766
|
+
return outputAllPassed && toolEval.evals.every((v) => v.passed);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* The run's final pass/fail after cost-tiered fail-fast: the {@link
|
|
770
|
+
* computeCompositeVerdict} of the tiers that RAN, AND no tiers were skipped. A
|
|
771
|
+
* fail-fast run that gated higher tiers is NEVER a pass (skipped ≠ passed) — this
|
|
772
|
+
* forces `false` so the exit code is EvalFailure (4), never downgraded to 0 by the
|
|
773
|
+
* composite path alone. Pure + unit-testable.
|
|
774
|
+
*/
|
|
775
|
+
export function resolveCompositeAllPassed(outputAllPassed, toolEval, skipped) {
|
|
776
|
+
return computeCompositeVerdict(outputAllPassed, toolEval) && skipped === undefined;
|
|
777
|
+
}
|
|
778
|
+
/** Fold one session's `total_cost_usd` into the accumulator; a non-number (mock spawn / missing result) is ignored. */
|
|
779
|
+
export function recordSessionCost(acc, totalCostUsd) {
|
|
780
|
+
if (typeof totalCostUsd !== 'number' || !Number.isFinite(totalCostUsd))
|
|
781
|
+
return;
|
|
782
|
+
acc.totalUsd += totalCostUsd;
|
|
783
|
+
acc.sessions += 1;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* The ` | ≈$0.42 across 6 sessions` spend suffix for the summary line, or `''`
|
|
787
|
+
* when no session reported a cost (e.g. every spawn was a test mock) so the suffix
|
|
788
|
+
* never adds noise to a run with no cost signal. Pure + unit-testable.
|
|
789
|
+
*/
|
|
790
|
+
export function formatRunCostSuffix(cost) {
|
|
791
|
+
if (cost === undefined || cost.sessions === 0)
|
|
792
|
+
return '';
|
|
793
|
+
return ` | ≈$${cost.totalUsd.toFixed(2)} across ${cost.sessions} session${cost.sessions === 1 ? '' : 's'}`;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* The run's human summary line, computed from the COMPOSITE verdict so an
|
|
797
|
+
* output-pass with a failing tool verdict still reads FAIL. The prose-expectation
|
|
798
|
+
* counts (`passed/total`) come from the grading verdict; when any tool-expectation
|
|
799
|
+
* verdict failed, a `(N tool)` suffix names how many — so a composite FAIL whose
|
|
800
|
+
* OUTPUT counts look all-green (e.g. `FAIL 3/3 (1 tool)`) is self-explaining. Pure.
|
|
801
|
+
*/
|
|
802
|
+
export function buildRunSummary(verdict, toolEval, compositeAllPassed) {
|
|
803
|
+
const base = `${compositeAllPassed ? 'PASS' : 'FAIL'} ${verdict.passed}/${verdict.total}`;
|
|
804
|
+
const toolFailures = toolEval.evals.filter((v) => !v.passed).length;
|
|
805
|
+
return toolFailures > 0 ? `${base} (${toolFailures} tool)` : base;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The run summary line, appending the fail-fast SKIPPED note when the tier gate
|
|
809
|
+
* stopped higher tiers. Legibility is required — the skipped tiers are named on
|
|
810
|
+
* their own line, never silently dropped. Pure. Note that a run with skipped
|
|
811
|
+
* tiers ALWAYS reads FAIL (skipped ≠ passed forces `compositeAllPassed` false at
|
|
812
|
+
* the call site), so the base line is already FAIL when the note is present.
|
|
813
|
+
*/
|
|
814
|
+
export function buildRunSummaryWithSkips(verdict, toolEval, compositeAllPassed, skipped, cost) {
|
|
815
|
+
// The spend suffix rides the verdict line (like `(N tool)`); the skipped-tiers
|
|
816
|
+
// note stays on its own following line.
|
|
817
|
+
const base = buildRunSummary(verdict, toolEval, compositeAllPassed) + formatRunCostSuffix(cost);
|
|
818
|
+
return skipped === undefined ? base : `${base}\n${formatSkippedTiersSummary(skipped)}`;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* D2 fail-closed gate: re-read ONE vat-written results artifact and assert it
|
|
822
|
+
* exists, parses as JSON, and validates against its schema. vat is the SOLE writer
|
|
823
|
+
* of everything under results/, so a missing/unparseable/invalid file here is a
|
|
824
|
+
* HARNESS bug ({@link InternalHarnessError} → exit 1), never a skill fault. This is
|
|
825
|
+
* safe precisely because vat always writes these after the merge succeeds.
|
|
826
|
+
*/
|
|
827
|
+
function assertVatWroteArtifact(path, validate, label) {
|
|
828
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
829
|
+
if (!existsSync(path)) {
|
|
830
|
+
throw new InternalHarnessError(`vat did not write ${label} at ${path} after the merge — harness bug.`);
|
|
831
|
+
}
|
|
832
|
+
let raw;
|
|
833
|
+
try {
|
|
834
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
835
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
throw new InternalHarnessError(`vat-written ${label} at ${path} is not valid JSON (harness bug): ${err instanceof Error ? err.message : String(err)}`);
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
validate(raw);
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
throw new InternalHarnessError(`vat-written ${label} at ${path} failed its schema (harness bug): ${err instanceof Error ? err.message : String(err)}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Post-merge D2 gate over the vat-written results artifacts (vat is SOLE writer):
|
|
849
|
+
* grading.json, friction.json, and tool-eval.json must each exist + parse + validate.
|
|
850
|
+
* A separate, explicit check (NOT folded into any prompt-invariant linter) — fail-closed
|
|
851
|
+
* and safe because vat wrote every one of these itself just above.
|
|
852
|
+
*/
|
|
853
|
+
function assertVatWroteArtifacts(paths) {
|
|
854
|
+
assertVatWroteArtifact(paths.gradingOut, (raw) => { GradingReportSchema.parse(raw); }, 'grading.json');
|
|
855
|
+
assertVatWroteArtifact(paths.frictionOut, (raw) => { FrictionReportSchema.parse(raw); }, 'friction.json');
|
|
856
|
+
assertVatWroteArtifact(paths.toolEvalOut, (raw) => { ToolEvalReportSchema.parse(raw); }, 'tool-eval.json');
|
|
857
|
+
}
|
|
538
858
|
// ---------------------------------------------------------------------------
|
|
539
859
|
// Main orchestrator
|
|
540
860
|
// ---------------------------------------------------------------------------
|
|
@@ -575,15 +895,33 @@ export async function runSkillTestHarness(opts) {
|
|
|
575
895
|
// Release the lock and remove the harness dir if the run is interrupted by
|
|
576
896
|
// SIGINT/SIGTERM — a try/finally alone does not run on a signal, so without
|
|
577
897
|
// this the lockfile (and staged bytes) would leak and break the next run.
|
|
898
|
+
//
|
|
899
|
+
// The vat-only grader dir lives OUTSIDE harnessRoot (forgery-proofing, see
|
|
900
|
+
// resolveGraderOutDir) so cleanupHarness does not reach it — cleanup removes it
|
|
901
|
+
// separately. Referenced via the mutable `graderOutDir` below so a signal that
|
|
902
|
+
// fires mid-pipeline reaps it too, not just the normal finally.
|
|
903
|
+
let graderOutDir;
|
|
578
904
|
const cleanup = () => {
|
|
905
|
+
// Reap any still-in-flight executor/grader children FIRST. On the concurrent
|
|
906
|
+
// error path `Promise.all` rejects without cancelling its siblings, and a
|
|
907
|
+
// `process.exit` tears down their in-process watchdog timers — so without
|
|
908
|
+
// this, up to `concurrency-1` detached `claude` sessions would be orphaned
|
|
909
|
+
// and keep billing tokens. Kill them before removing the dirs they write to.
|
|
910
|
+
// NOTE: the registry is a module-level singleton, so this assumes one run per
|
|
911
|
+
// process (true for the CLI). A library embedding two concurrent runs in one
|
|
912
|
+
// process would need per-run child scoping instead.
|
|
913
|
+
killAllActiveClaudeChildren();
|
|
579
914
|
lock.release();
|
|
580
915
|
cleanupHarness(harnessRoot, { keep: opts.keep === true, created: harnessCreated });
|
|
916
|
+
removeGraderOutDir(graderOutDir);
|
|
581
917
|
};
|
|
582
918
|
const removeSignalCleanup = installSignalCleanup({ onSignal: cleanup });
|
|
583
919
|
// Hoisted so the finally can surface packaging friction even when a throw
|
|
584
|
-
// (
|
|
585
|
-
// normal verdict path. friction.json is written
|
|
586
|
-
//
|
|
920
|
+
// (a grader/executor failure, the per-fragment nonce guard, a spawn timeout)
|
|
921
|
+
// preempts the normal verdict path. friction.json is written by VAT AFTER the
|
|
922
|
+
// executor→grader pipeline merges the grader fragments — so it is present only
|
|
923
|
+
// once merging succeeded; on a pre-merge throw the finally simply finds no
|
|
924
|
+
// friction.json and emits nothing (a no-op).
|
|
587
925
|
let frictionOut;
|
|
588
926
|
try {
|
|
589
927
|
// Step 3: Stage the harness FIRST — the subject's own evals/evals.json lands
|
|
@@ -625,11 +963,14 @@ export async function runSkillTestHarness(opts) {
|
|
|
625
963
|
summary: `Preflight failed:\n${summary}`,
|
|
626
964
|
};
|
|
627
965
|
}
|
|
628
|
-
// Step 5.5: Parse the eval suite and stage per-eval input workspaces.
|
|
966
|
+
// Step 5.5: Parse the eval suite and stage per-eval input workspaces. The
|
|
967
|
+
// parsed suite is threaded on so the eval loop has the entries without
|
|
968
|
+
// re-reading; declaredEvalCount is derived from it (suite.evals.length).
|
|
629
969
|
const workspaceStageResult = attemptStageWorkspaces(evalsPath, harnessRoot);
|
|
630
970
|
if ('exitCode' in workspaceStageResult)
|
|
631
971
|
return workspaceStageResult;
|
|
632
|
-
const { workspacesRoot, declaredEvalCount } = workspaceStageResult;
|
|
972
|
+
const { workspacesRoot, declaredEvalCount, suite } = workspaceStageResult;
|
|
973
|
+
emitEvalLintWarnings(suite.evals, (opts.declaredExecutables ?? []).map((e) => e.name));
|
|
633
974
|
// Step 6: Enforce the §12 security ack (must pass --i-understand-this-runs-skill-code).
|
|
634
975
|
// Only enforced when not a dry-run and not already acknowledged.
|
|
635
976
|
if (!isAcknowledged(opts)) {
|
|
@@ -639,7 +980,9 @@ export async function runSkillTestHarness(opts) {
|
|
|
639
980
|
summary: 'Security acknowledgment required. Pass --i-understand-this-runs-skill-code to proceed.',
|
|
640
981
|
};
|
|
641
982
|
}
|
|
642
|
-
// Step 7:
|
|
983
|
+
// Step 7: Results dir + provenance. vat is the SOLE writer of everything under
|
|
984
|
+
// results/ — grading.json/friction.json/baseline.json come from the merged
|
|
985
|
+
// grader fragments below, never from the (untrusted) model.
|
|
643
986
|
const resultsDir = safePath.joinUnderRoot(harnessRoot, 'results');
|
|
644
987
|
mkdirSyncReal(resultsDir, { recursive: true });
|
|
645
988
|
// Record what was actually staged & tested (the subject identity + the
|
|
@@ -655,52 +998,40 @@ export async function runSkillTestHarness(opts) {
|
|
|
655
998
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived results path
|
|
656
999
|
writeFileSync(provenancePath, JSON.stringify(provenance, null, 2) + '\n', 'utf-8');
|
|
657
1000
|
process.stderr.write(`Provenance: ${provenance.fingerprint}\n`);
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// out. The full prompt (with the nonce) reaches claude only via stdin (see
|
|
676
|
-
// spawnHeadlessClaude) and is NEVER written to disk; the persisted audit copy
|
|
677
|
-
// below is redacted so skill code can't read the nonce back and forge a match.
|
|
678
|
-
const runNonce = randomBytes(16).toString('hex');
|
|
679
|
-
const noncedPrompt = appendIntegrityNonceDirective(effectivePrompt, runNonce);
|
|
680
|
-
// Step 8: Write the REDACTED experimenter prompt as the audit artifact. The
|
|
681
|
-
// real (nonce-bearing) prompt is passed to the spawn in memory, not from here.
|
|
682
|
-
const promptFile = safePath.join(resultsDir, 'experimenter-prompt.txt');
|
|
683
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
|
|
684
|
-
writeFileSync(promptFile, redactNonce(noncedPrompt, runNonce) + '\n', 'utf-8');
|
|
685
|
-
// Step 7.5: Resolve the declared test env (Features A + B). Token resolution
|
|
686
|
-
// can hard-fail (exit 2) on an unknown ${token}; do it before the dry-run
|
|
687
|
-
// short-circuit so a dry run validates interpolation too.
|
|
1001
|
+
// Resolve the results/ artifact paths and WIPE any prior run's artifacts up
|
|
1002
|
+
// front: harnessRoot is deterministic and may be reused (--keep/--out, or after
|
|
1003
|
+
// a crash before cleanup), so a stale grading/friction/baseline.json must never
|
|
1004
|
+
// leak into — or be echoed by the finally on — this run. `frictionOut` is
|
|
1005
|
+
// hoisted so the finally can echo THIS run's friction (and only after the wipe).
|
|
1006
|
+
const artifacts = resolveArtifactPaths(resultsDir);
|
|
1007
|
+
wipeStaleArtifacts(artifacts);
|
|
1008
|
+
frictionOut = artifacts.frictionOut;
|
|
1009
|
+
// Step 7.5: Resolve the executor's declared test env (Features A + B). Token
|
|
1010
|
+
// resolution can hard-fail (exit 2) on an unknown ${token}; do it before the
|
|
1011
|
+
// dry-run short-circuit so a dry run validates interpolation too. resolvedAuth
|
|
1012
|
+
// must be non-null once preflight passed — a null would mean spawning with an
|
|
1013
|
+
// unscrubbed env, which we refuse (it would hand skill code every secret).
|
|
1014
|
+
const { resolvedAuth } = preflightResult;
|
|
1015
|
+
if (resolvedAuth === null) {
|
|
1016
|
+
throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
|
|
1017
|
+
}
|
|
688
1018
|
const assembledEnv = resolveDeclaredChildEnv({
|
|
689
1019
|
opts,
|
|
690
|
-
resolvedAuth
|
|
1020
|
+
resolvedAuth,
|
|
691
1021
|
subjectStagedDir,
|
|
692
1022
|
harnessRoot,
|
|
693
1023
|
resultsDir,
|
|
694
1024
|
evalsSubpath,
|
|
695
1025
|
subjectPluginRoot,
|
|
696
1026
|
});
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
|
|
1027
|
+
// Resolve the run knobs: executor (subject) model, pinned grader model, and
|
|
1028
|
+
// pipeline width. graderModel is deliberately distinct from the subject model
|
|
1029
|
+
// so the judge stays comparable across runs.
|
|
1030
|
+
const graderModel = opts.graderModel ?? DEFAULT_GRADER_MODEL;
|
|
1031
|
+
const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
|
|
701
1032
|
const modelFlag = buildModelFlag(knobs.model);
|
|
702
|
-
process.stderr.write(`Model: ${knobs.model ?? '(claude default)'}\n`);
|
|
703
|
-
// Step
|
|
1033
|
+
process.stderr.write(`Model: ${knobs.model ?? '(claude default)'} | grader: ${graderModel}\n`);
|
|
1034
|
+
// Step 8: Dry-run short-circuit — return assembled info without spawning.
|
|
704
1035
|
if (opts.dryRun === true) {
|
|
705
1036
|
return {
|
|
706
1037
|
harnessPath: harnessRoot,
|
|
@@ -714,67 +1045,89 @@ export async function runSkillTestHarness(opts) {
|
|
|
714
1045
|
provenanceFingerprint: provenance.fingerprint,
|
|
715
1046
|
provenanceEntryCount: provenance.entries.length,
|
|
716
1047
|
modelFlag,
|
|
1048
|
+
evalCount: declaredEvalCount,
|
|
1049
|
+
concurrency,
|
|
1050
|
+
graderModel,
|
|
717
1051
|
}),
|
|
718
1052
|
};
|
|
719
1053
|
}
|
|
720
|
-
// Step
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1054
|
+
// Step 9: Run the vat-owned executor→grader pipeline. ONE secret per-run nonce
|
|
1055
|
+
// is stamped into every grader prompt (via stdin) and re-verified per fragment
|
|
1056
|
+
// on merge — this is what distinguishes a fragment produced by a grader WE
|
|
1057
|
+
// prompted from one forged/left behind by untrusted skill code in the executor's
|
|
1058
|
+
// sandbox. The grader dir is created OUTSIDE harnessRoot (forgery-proof; see
|
|
1059
|
+
// resolveGraderOutDir) at 0700, and removed in the finally / on signal.
|
|
1060
|
+
//
|
|
1061
|
+
// The dir is named by an INDEPENDENT random token (graderDirToken), NOT the
|
|
1062
|
+
// nonce: the dir name and the grader's --add-dir argv are world-listable in the
|
|
1063
|
+
// shared OS temp dir, so encoding the secret nonce there would let same-user
|
|
1064
|
+
// skill code read it off `ls <tmp>` and forge a valid-nonce fragment. The nonce
|
|
1065
|
+
// is kept off disk / off argv — delivered to the grader only via its stdin prompt.
|
|
1066
|
+
const runNonce = randomBytes(16).toString('hex');
|
|
1067
|
+
const graderDirToken = randomBytes(16).toString('hex');
|
|
1068
|
+
graderOutDir = resolveGraderOutDir(graderDirToken);
|
|
1069
|
+
mkdirSyncReal(graderOutDir, { recursive: true, mode: 0o700 });
|
|
1070
|
+
// Run-wide spend accumulator: each worker folds in its executor + grader
|
|
1071
|
+
// session cost, surfaced as a `≈$X across N sessions` suffix on the summary.
|
|
1072
|
+
const costAccumulator = { totalUsd: 0, sessions: 0 };
|
|
1073
|
+
const evalCtx = {
|
|
1074
|
+
subjectStagedDir,
|
|
1075
|
+
workspacesRoot,
|
|
731
1076
|
pluginDirs,
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
timeoutMs,
|
|
738
|
-
onStdout: (chunk) => { process.stderr.write(chunk); },
|
|
739
|
-
onStderr: (chunk) => { process.stderr.write(chunk); },
|
|
1077
|
+
graderOutDir,
|
|
1078
|
+
runNonce,
|
|
1079
|
+
graderModel,
|
|
1080
|
+
costAccumulator,
|
|
1081
|
+
...(knobs.model === undefined ? {} : { model: knobs.model }),
|
|
740
1082
|
maxTurns: knobs.maxTurns,
|
|
741
1083
|
maxBudgetUsd: knobs.maxBudgetUsd,
|
|
742
|
-
|
|
1084
|
+
timeoutMs: resolveTimeoutMs(opts),
|
|
743
1085
|
...(knobs.stallMs === undefined ? {} : { stallMs: knobs.stallMs }),
|
|
1086
|
+
// Executor gets the full assembled env (the skill needs its injected
|
|
1087
|
+
// secrets); the grader is trusted vat infra loading no skill, so it gets
|
|
1088
|
+
// AUTH-ONLY env — never the skill's injected secrets.
|
|
1089
|
+
executorEnv: assembledEnv.env,
|
|
1090
|
+
graderEnv: resolvedAuth.forwardedEnv,
|
|
1091
|
+
...(opts.declaredExecutables === undefined ? {} : { declaredExecutables: opts.declaredExecutables }),
|
|
1092
|
+
...(opts.spawn === undefined ? {} : { spawn: opts.spawn }),
|
|
744
1093
|
};
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
// the
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
1094
|
+
// Tier-ordered, cost-tiered fail-fast (issue #145 Phase G): run evals tier by
|
|
1095
|
+
// tier (ascending / cheapest first), bounded-parallel WITHIN each tier, and
|
|
1096
|
+
// gate BETWEEN tiers — once a cheaper tier fails a gating expectation, the
|
|
1097
|
+
// higher (more expensive) tiers are SKIPPED (never graded, never passed), so a
|
|
1098
|
+
// broken foundational expectation stops the run from spending on hard tiers.
|
|
1099
|
+
// Each tier is one runPipeline: bounded-parallel, retrying a RateLimitSignal
|
|
1100
|
+
// per item. An InternalHarnessError thrown by any executor/grader (timeout/
|
|
1101
|
+
// stall/spawn-error, grader failure, missing fragment, nonce mismatch)
|
|
1102
|
+
// propagates OUT unhandled → mapErrorToExitCode → exit 1; a spawn or grader
|
|
1103
|
+
// break is never laundered into a pass/fail verdict (R1 no-laundering).
|
|
1104
|
+
const { fragments, skipped } = await runEvalsTiered({
|
|
1105
|
+
evals: suite.evals,
|
|
1106
|
+
baseline: opts.baseline === true,
|
|
1107
|
+
runTier: (items) => runPipeline({
|
|
1108
|
+
items,
|
|
1109
|
+
concurrency,
|
|
1110
|
+
worker: (item) => runEvalWorker(item, evalCtx),
|
|
1111
|
+
}),
|
|
1112
|
+
});
|
|
1113
|
+
// Step 10: vat merges the grader fragments and writes grading.json/friction.json/
|
|
1114
|
+
// tool-eval.json (and baseline.json for a baseline run), then reconciles the
|
|
1115
|
+
// authoritative prose-expectation verdict from the WITH-arm per-expectation
|
|
1116
|
+
// `passed` flags — NOT any self-reported summary. An executor CLEAN failure
|
|
1117
|
+
// reaches here as a FAILing fragment → composite verdict → exit 4.
|
|
1118
|
+
const { verdict, toolEval } = writeRunArtifactsAndReconcile(fragments, runNonce, artifacts);
|
|
1119
|
+
// D2 fail-closed gate: vat is the SOLE writer of results/, so a missing/unparseable/
|
|
1120
|
+
// invalid grading.json, friction.json, or tool-eval.json after the merge is a HARNESS
|
|
1121
|
+
// bug (exit 1), never a skill fault. A SEPARATE explicit check, not a prompt invariant.
|
|
1122
|
+
assertVatWroteArtifacts(artifacts);
|
|
1123
|
+
// Composite verdict: the run passes only when BOTH output expectations AND every
|
|
1124
|
+
// tool-expectation verdict passed — so an output all-pass where a `mustRun` never
|
|
1125
|
+
// ran yields FAIL → exit 4. Tool verdicts stay in tool-eval.json (C2); the composite
|
|
1126
|
+
// combines the two channels only here, at the exit-code layer. A fail-fast run
|
|
1127
|
+
// that SKIPPED higher tiers is never a pass: skipped ≠ passed forces allPassed
|
|
1128
|
+
// false → exit 4 (never downgraded to 0 by the composite path).
|
|
1129
|
+
const allPassed = resolveCompositeAllPassed(verdict.allPassed, toolEval, skipped);
|
|
1130
|
+
const summary = buildRunSummaryWithSkips(verdict, toolEval, allPassed, skipped, costAccumulator);
|
|
778
1131
|
return {
|
|
779
1132
|
harnessPath: harnessRoot,
|
|
780
1133
|
exitCode: verdictExitCode(allPassed, opts.tolerateEvalFailure === true),
|
|
@@ -782,17 +1135,14 @@ export async function runSkillTestHarness(opts) {
|
|
|
782
1135
|
};
|
|
783
1136
|
}
|
|
784
1137
|
finally {
|
|
785
|
-
// Surface any packaging-fidelity friction
|
|
786
|
-
// BEFORE cleanup removes the harness dir.
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
// runs the friction ("your bundle is missing") is the key diagnostic and would
|
|
790
|
-
// otherwise be masked by the error. Best-effort + stderr-only (stdout stays
|
|
791
|
-
// machine-readable); a no-op when the path is unassigned, or friction.json
|
|
792
|
-
// is absent or empty.
|
|
1138
|
+
// Surface any packaging-fidelity friction VAT merged into friction.json to
|
|
1139
|
+
// STDERR BEFORE cleanup removes the harness dir. Best-effort + stderr-only
|
|
1140
|
+
// (stdout stays machine-readable); a no-op when the path is unassigned (a
|
|
1141
|
+
// pre-merge throw) or friction.json is absent or empty.
|
|
793
1142
|
emitFrictionReport(frictionOut);
|
|
794
1143
|
// Remove the signal handlers first (no listener leak across runs), then run
|
|
795
|
-
// the same cleanup: release the lock,
|
|
1144
|
+
// the same cleanup: release the lock, remove the harness dir, and remove the
|
|
1145
|
+
// vat-only grader dir (which lives outside harnessRoot).
|
|
796
1146
|
removeSignalCleanup();
|
|
797
1147
|
cleanup();
|
|
798
1148
|
}
|