@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.
Files changed (88) hide show
  1. package/dist/skill-test/eval-executor.d.ts +50 -0
  2. package/dist/skill-test/eval-executor.d.ts.map +1 -0
  3. package/dist/skill-test/eval-executor.js +85 -0
  4. package/dist/skill-test/eval-executor.js.map +1 -0
  5. package/dist/skill-test/eval-fragment.d.ts +278 -0
  6. package/dist/skill-test/eval-fragment.d.ts.map +1 -0
  7. package/dist/skill-test/eval-fragment.js +128 -0
  8. package/dist/skill-test/eval-fragment.js.map +1 -0
  9. package/dist/skill-test/eval-grader.d.ts +54 -0
  10. package/dist/skill-test/eval-grader.d.ts.map +1 -0
  11. package/dist/skill-test/eval-grader.js +184 -0
  12. package/dist/skill-test/eval-grader.js.map +1 -0
  13. package/dist/skill-test/eval-inputs.d.ts +355 -0
  14. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  15. package/dist/skill-test/eval-inputs.js +36 -8
  16. package/dist/skill-test/eval-inputs.js.map +1 -1
  17. package/dist/skill-test/eval-lint.d.ts +51 -0
  18. package/dist/skill-test/eval-lint.d.ts.map +1 -0
  19. package/dist/skill-test/eval-lint.js +157 -0
  20. package/dist/skill-test/eval-lint.js.map +1 -0
  21. package/dist/skill-test/evals-template.d.ts.map +1 -1
  22. package/dist/skill-test/evals-template.js +7 -0
  23. package/dist/skill-test/evals-template.js.map +1 -1
  24. package/dist/skill-test/executor-prompt.d.ts +41 -0
  25. package/dist/skill-test/executor-prompt.d.ts.map +1 -0
  26. package/dist/skill-test/executor-prompt.js +63 -0
  27. package/dist/skill-test/executor-prompt.js.map +1 -0
  28. package/dist/skill-test/exit-codes.d.ts +8 -5
  29. package/dist/skill-test/exit-codes.d.ts.map +1 -1
  30. package/dist/skill-test/exit-codes.js +12 -7
  31. package/dist/skill-test/exit-codes.js.map +1 -1
  32. package/dist/skill-test/fragment-merge.d.ts +46 -0
  33. package/dist/skill-test/fragment-merge.d.ts.map +1 -0
  34. package/dist/skill-test/fragment-merge.js +77 -0
  35. package/dist/skill-test/fragment-merge.js.map +1 -0
  36. package/dist/skill-test/friction-schema.d.ts +10 -10
  37. package/dist/skill-test/friction-schema.d.ts.map +1 -1
  38. package/dist/skill-test/friction-schema.js +6 -1
  39. package/dist/skill-test/friction-schema.js.map +1 -1
  40. package/dist/skill-test/grader-model.d.ts +6 -0
  41. package/dist/skill-test/grader-model.d.ts.map +1 -0
  42. package/dist/skill-test/grader-model.js +6 -0
  43. package/dist/skill-test/grader-model.js.map +1 -0
  44. package/dist/skill-test/grader-prompt.d.ts +74 -0
  45. package/dist/skill-test/grader-prompt.d.ts.map +1 -0
  46. package/dist/skill-test/grader-prompt.js +173 -0
  47. package/dist/skill-test/grader-prompt.js.map +1 -0
  48. package/dist/skill-test/grading-adapter.d.ts +5 -11
  49. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  50. package/dist/skill-test/grading-adapter.js +9 -22
  51. package/dist/skill-test/grading-adapter.js.map +1 -1
  52. package/dist/skill-test/grading-schema.d.ts +18 -15
  53. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  54. package/dist/skill-test/grading-schema.js +6 -5
  55. package/dist/skill-test/grading-schema.js.map +1 -1
  56. package/dist/skill-test/index.d.ts +13 -3
  57. package/dist/skill-test/index.d.ts.map +1 -1
  58. package/dist/skill-test/index.js +13 -3
  59. package/dist/skill-test/index.js.map +1 -1
  60. package/dist/skill-test/pipeline.d.ts +29 -0
  61. package/dist/skill-test/pipeline.d.ts.map +1 -0
  62. package/dist/skill-test/pipeline.js +77 -0
  63. package/dist/skill-test/pipeline.js.map +1 -0
  64. package/dist/skill-test/prompt-invariants.d.ts +27 -0
  65. package/dist/skill-test/prompt-invariants.d.ts.map +1 -0
  66. package/dist/skill-test/prompt-invariants.js +38 -0
  67. package/dist/skill-test/prompt-invariants.js.map +1 -0
  68. package/dist/skill-test/run-harness.d.ts +187 -55
  69. package/dist/skill-test/run-harness.d.ts.map +1 -1
  70. package/dist/skill-test/run-harness.js +539 -189
  71. package/dist/skill-test/run-harness.js.map +1 -1
  72. package/dist/skill-test/tier-plan.d.ts +89 -0
  73. package/dist/skill-test/tier-plan.d.ts.map +1 -0
  74. package/dist/skill-test/tier-plan.js +96 -0
  75. package/dist/skill-test/tier-plan.js.map +1 -0
  76. package/dist/skill-test/tool-eval-schema.d.ts +412 -0
  77. package/dist/skill-test/tool-eval-schema.d.ts.map +1 -0
  78. package/dist/skill-test/tool-eval-schema.js +83 -0
  79. package/dist/skill-test/tool-eval-schema.js.map +1 -0
  80. package/dist/validators/packaging-validator.d.ts +8 -1
  81. package/dist/validators/packaging-validator.d.ts.map +1 -1
  82. package/dist/validators/packaging-validator.js.map +1 -1
  83. package/package.json +5 -5
  84. package/schemas/friction-report.json +2 -1
  85. package/dist/skill-test/experimenter-prompt.d.ts +0 -42
  86. package/dist/skill-test/experimenter-prompt.d.ts.map +0 -1
  87. package/dist/skill-test/experimenter-prompt.js +0 -104
  88. 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 effective prompt + assertPromptInvariants dry-run short-circuit
9
- * → spawnHeadlessClaudeparseGradingJson release lock return result
8
+ * → dry-run short-circuit → build per-eval work items → run the vat-owned
9
+ * executorgrader 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, spawnHeadlessClaude, toForwardSlash, } from '@vibe-agent-toolkit/utils';
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 { appendIntegrityNonceDirective, assertPromptInvariants, buildExperimenterPrompt, redactNonce, } from './experimenter-prompt.js';
26
+ import { mergeFragmentsToFriction, mergeFragmentsToGrading, mergeFragmentsToToolEval } from './fragment-merge.js';
22
27
  import { FrictionReportSchema } from './friction-schema.js';
23
- import { assertGradingNonce, parseGradingJson, reconcileGrading } from './grading-adapter.js';
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 for the experimenter spawn. These are the
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
- * Compute the DEFAULT wall-clock budget (ms) when the operator gave no explicit
68
- * `--timeout`. It scales with the number of declared evals so a large suite isn't
69
- * killed at the flat 5-minute default before it can finish: a 120s base plus 120s
70
- * per declared eval, floored at the historical 300s default (so we never shrink
71
- * below today's budget) and capped at 1h (so a runaway/huge suite can't request an
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, declaredEvalCount) {
84
- if (opts.timeout !== undefined)
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 grading rubric / schema source the experimenter is told
235
- // to reuse. A missing, unparseable, or mutated manifest fails preflight (exit 2).
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 experimenter surfaced
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 AND the number of
402
- * evals the suite declared (so the caller can scale the default timeout without
403
- * re-reading the suite). The dir is wiped first so a reused harness root cannot
404
- * leak a prior run's inputs. Throws {@link EvalInputError} (mapped by the caller to
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
- * Assemble the experimenter's child env: the scrubbed forwarded env unioned with
446
- * the declared test env (Features A + B) and CLAUDE_PLUGIN_ROOT, then emit the
447
- * transparency line and any protected-key collision warnings to stderr.
448
- *
449
- * Refuses to assemble without a resolved auth a null here is an internal
450
- * invariant violation (preflight passed), never a reason to fall back to
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 resolveDeclaredChildEnv(input) {
454
- if (input.resolvedAuth === null) {
455
- throw new InternalHarnessError('Internal: preflight passed but resolvedAuth is null — refusing to spawn with an unscrubbed environment.');
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 spawn: claude -p ${input.modelFlag} (prompt via stdin)`, `[dry-run] Staged manifest: ${count} entr${count === 1 ? 'y' : 'ies'} | fingerprint: ${input.provenanceFingerprint}`, `[dry-run] Provenance: ${input.provenancePath}`);
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
- // (missing/invalid grading.json, nonce/skew guard, timeout) preempts the
585
- // normal verdict path. friction.json is written at STAGING time (pre-spawn),
586
- // so it is present and meaningful on exactly those broken-run paths.
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: Build effective experimenter prompt and validate invariants.
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
- const gradingOut = safePath.join(resultsDir, 'grading.json');
659
- frictionOut = safePath.join(resultsDir, 'friction.json');
660
- const effectivePrompt = opts.promptOverride ??
661
- buildExperimenterPrompt({
662
- subjectPath: subjectStagedDir,
663
- evalsPath,
664
- gradingOut,
665
- frictionOut,
666
- workspacesRoot,
667
- baseline: opts.baseline ?? false,
668
- });
669
- assertPromptInvariants(effectivePrompt);
670
- // Integrity nonce (Harness B): stamp a secret per-run nonce the experimenter
671
- // must echo into grading.json, and verify it after the run. This is what
672
- // distinguishes a grading produced by the experimenter WE prompted from one
673
- // forged/left behind by untrusted skill code in the shared sandbox. The nonce
674
- // is appended AFTER any user prompt override so a committed config can't opt
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: preflightResult.resolvedAuth,
1020
+ resolvedAuth,
691
1021
  subjectStagedDir,
692
1022
  harnessRoot,
693
1023
  resultsDir,
694
1024
  evalsSubpath,
695
1025
  subjectPluginRoot,
696
1026
  });
697
- // Echo the selected model on EVERY run (regular + dry-run) so it is
698
- // unambiguous which model the experimenter spawn uses. The id is forwarded
699
- // verbatim to `claude --model`; with none set, no flag is passed and claude
700
- // uses its own default.
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 9: Dry-run short-circuit — return assembled info without spawning.
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 10: Spawn headless Claude.
721
- // A reused harness root (--out / --keep) may carry a grading.json OR a
722
- // friction.json from an earlier run. Remove both so a post-spawn read can
723
- // only reflect THIS run (a stale friction.json must not leak across runs).
724
- rmSync(gradingOut, { force: true });
725
- rmSync(frictionOut, { force: true });
726
- const timeoutMs = resolveTimeoutMs(opts, declaredEvalCount);
727
- const spawnOpts = {
728
- // In-memory, nonce-bearing prompt streamed to stdin, never written to a
729
- // file the skill could read (see the integrity-nonce note above).
730
- prompt: noncedPrompt + '\n',
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
- sandboxDir: harnessRoot,
733
- cwd: harnessRoot,
734
- // The scrubbed forwarded env, unioned with the declared test env (Features
735
- // A + B) and CLAUDE_PLUGIN_ROOT when the subject is plugin-distributed.
736
- env: assembledEnv.env,
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
- ...(knobs.model === undefined ? {} : { model: knobs.model }),
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
- const spawnResult = await spawnHeadlessClaude(spawnOpts);
746
- // Thread the declared eval count so a timeout message can explain WHY the budget
747
- // may be too small for a large suite (no completed count: it isn't cheaply/safely
748
- // available here all workspaces are staged up front, so subdir count ≠ progress).
749
- assertExperimenterSucceeded(spawnResult, knobs.stallMs, timeoutMs, { declaredEvalCount });
750
- // Step 11: Parse grading.json must be present and valid.
751
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
752
- if (!existsSync(gradingOut)) {
753
- throw new InternalHarnessError(`Experimenter exited (status ${spawnResult.status}) without writing grading.json at ${gradingOut}.`);
754
- }
755
- let gradingRaw;
756
- try {
757
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- our own derived path
758
- gradingRaw = JSON.parse(readFileSync(gradingOut, 'utf-8'));
759
- }
760
- catch (e) {
761
- throw new InternalHarnessError(`grading.json is not valid JSON: ${String(e)}`);
762
- }
763
- const grading = parseGradingJson(gradingRaw);
764
- // Integrity gate (Harness B): reject a grading.json that doesn't echo THIS
765
- // run's secret nonce BEFORE trusting any verdict a missing/wrong nonce means
766
- // the grading was not produced by the experimenter we prompted (e.g. forged by
767
- // skill code in the sandbox). Thrown GradingNonceError maps to exit 1.
768
- assertGradingNonce(grading.runNonce, runNonce);
769
- // Verdict comes from the authoritative per-expectation `passed` flags, NOT
770
- // the grader's self-reported summary. reconcileGrading throws GradingSkewError
771
- // if the grader graded nothing or its summary disagrees with the expectations.
772
- const { passed, total, allPassed } = reconcileGrading(grading);
773
- const summary = `${allPassed ? 'PASS' : 'FAIL'} ${passed}/${total}`;
774
- // Default (fail-closed): a failing verdict returns EvalFailure (4), distinct
775
- // from the harness-broke codes (1/2/3) so CI can gate on regressions. Opt-out:
776
- // with tolerateEvalFailure, a failing verdict is downgraded to Ok (0) and the
777
- // pass/fail count lives only in the summary string and grading.json.
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 the experimenter recorded to STDERR
786
- // BEFORE cleanup removes the harness dir. Placed in finally (not the happy path)
787
- // so friction still surfaces when a throw missing/invalid grading.json, the
788
- // nonce/skew guard, or a timeout preempts the verdict; on exactly those broken
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, then remove the harness dir.
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
  }