@skill-harness/core 0.8.0 → 0.9.0

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/results.js CHANGED
@@ -4,6 +4,29 @@ import yaml from "js-yaml";
4
4
  import { modelSlug } from "./adapters/types.js";
5
5
  import { score } from "./score.js";
6
6
  import { HARNESS_VERSION } from "./version.js";
7
+ /** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
8
+ export function mergeScenarioMetrics(prior, fresh) {
9
+ if (!prior)
10
+ return fresh;
11
+ if (!fresh)
12
+ return prior;
13
+ const subject = prior.subject_metrics_reps > 0 ? prior : fresh;
14
+ return {
15
+ wall_time_ms: prior.wall_time_ms + fresh.wall_time_ms,
16
+ judge_calls: prior.judge_calls + fresh.judge_calls,
17
+ judge_rejudge_calls: prior.judge_rejudge_calls + fresh.judge_rejudge_calls,
18
+ subject_metrics_reps: subject.subject_metrics_reps,
19
+ total_reps: prior.total_reps,
20
+ ...(subject.input_tokens === undefined ? {} : { input_tokens: subject.input_tokens }),
21
+ ...(subject.output_tokens === undefined ? {} : { output_tokens: subject.output_tokens }),
22
+ ...(subject.cache_read_tokens === undefined ? {} : { cache_read_tokens: subject.cache_read_tokens }),
23
+ ...(subject.cache_write_tokens === undefined ? {} : { cache_write_tokens: subject.cache_write_tokens }),
24
+ ...(subject.subject_cost_usd === undefined ? {} : { subject_cost_usd: subject.subject_cost_usd }),
25
+ ...(subject.tool_calls === undefined ? {} : { tool_calls: subject.tool_calls }),
26
+ ...(subject.delegated_children === undefined ? {} : { delegated_children: subject.delegated_children }),
27
+ ...(subject.max_concurrency === undefined ? {} : { max_concurrency: subject.max_concurrency }),
28
+ };
29
+ }
7
30
  /**
8
31
  * The run modes whose results carry a real grade: the ones where the skill under
9
32
  * test was actually delivered to the model.
@@ -53,6 +76,11 @@ export function scoreContextFor(run, spec) {
53
76
  }
54
77
  /** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
55
78
  export function effectiveThreshold(prevScenario, scenario) {
79
+ // Critical includes destructive/security/capability/data-loss AND right-sizing
80
+ // counterexamples. Every clean repetition must pass; an aggregate majority may
81
+ // not hide one release-blocking failure.
82
+ if (scenario.critical)
83
+ return 1;
56
84
  return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
57
85
  }
58
86
  /** Slugify an ISO timestamp into a filesystem-safe directory name. */
@@ -252,7 +280,7 @@ export function ensureResultsGitignore(resultsRoot) {
252
280
  // so `repIndexOf` returned null for traces and `regate` looked for an unsuffixed
253
281
  // path that does not exist on a multi-rep run — reporting "trace missing" for
254
282
  // traces sitting on disk.
255
- const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?(?:txt|trace\.jsonl)$/;
283
+ const REP_SUFFIX_RE = /\.rep(\d+)\.(?:(?:judge|diff)\.txt|(?:trace|events)\.jsonl|txt)$/;
256
284
  /** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
257
285
  export function repIndexOf(filename) {
258
286
  const m = REP_SUFFIX_RE.exec(filename);
@@ -356,7 +384,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
356
384
  export function rebuildScenarioResult(fresh, prior, policy) {
357
385
  // Exhaustive destructure. Do not replace with a spread: the spread is what
358
386
  // allowed a new field to pass through unconsidered in the first place.
359
- const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
387
+ const { id, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, ...rest } = fresh;
360
388
  const _exhaustive = rest;
361
389
  void _exhaustive;
362
390
  void _freshOverride;
@@ -367,7 +395,14 @@ export function rebuildScenarioResult(fresh, prior, policy) {
367
395
  return p === "fresh" ? freshValue : priorValue;
368
396
  };
369
397
  const objective = pick(policy.objective, freshObjective, prior?.objective);
370
- const adjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
398
+ const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
399
+ const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" ||
400
+ (pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
401
+ // A settled cell-level panel cannot overrule newly evaluated objective gates,
402
+ // nor can one transcript erase a failed repetition under an all-clean policy.
403
+ const adjudication = conflictsWithFreshEvidence && pickedAdjudication
404
+ ? { ...pickedAdjudication, state: "unresolved", verdict: undefined }
405
+ : pickedAdjudication;
371
406
  // `unresolved` is carried by the `suspect` flag and by nothing else — the
372
407
  // adjudication block records WHY, but `suspect` is what the ship bar reads.
373
408
  // Taking `suspect` from `fresh` while carrying the block therefore published a
@@ -382,7 +417,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
382
417
  // `<id>.judge.txt` would revert a `confirmed`/`tie_broken` verdict that two or
383
418
  // three judges settled — leaving `adjudication.verdict` on the record
384
419
  // contradicting the `judge_verdict` beside it.
385
- const settled = policy.adjudication === "carry" ? adjudication?.verdict : undefined;
420
+ const settled = policy.adjudication === "carry" && !conflictsWithFreshEvidence ? adjudication?.verdict : undefined;
386
421
  // Field ORDER matches `outcomesToResult`, the writer that produces a run's
387
422
  // results.yaml in the first place. It is not cosmetic: `results.yaml` is a
388
423
  // committed file, and emitting the same fields in a different order made every
@@ -400,6 +435,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
400
435
  ...(clean === undefined ? {} : { clean }),
401
436
  ...(flakiness === undefined ? {} : { flakiness }),
402
437
  ...(pass_threshold === undefined ? {} : { pass_threshold }),
438
+ ...((freshMetrics ?? prior?.metrics) ? { metrics: freshMetrics ?? prior.metrics } : {}),
403
439
  // The author owns the verdict; a re-measurement never discards their call.
404
440
  override: prior?.override ?? null,
405
441
  note: prior?.note ?? "",
@@ -421,6 +457,21 @@ export function tracePath(runDir, scenarioId, mode, rep) {
421
457
  const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
422
458
  return join(runDir, `${base}.trace.jsonl`);
423
459
  }
460
+ /** Saved adapter-neutral workflow events for one scenario rep. */
461
+ export function trajectoryPath(runDir, scenarioId, mode, rep) {
462
+ const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
463
+ return join(runDir, `${base}.events.jsonl`);
464
+ }
465
+ /** A scenario's normalized-event files, sorted (plain first, then numeric rep). */
466
+ export function findTrajectoryFiles(runDir, scenarioId, mode) {
467
+ if (!existsSync(runDir))
468
+ return [];
469
+ const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
470
+ const re = mode === undefined
471
+ ? new RegExp(`^${esc}\\..*\\.events\\.jsonl$`)
472
+ : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.events\\.jsonl$`);
473
+ return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
474
+ }
424
475
  /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
425
476
  export function findDiffFiles(runDir, scenarioId, mode) {
426
477
  if (!existsSync(runDir))
@@ -469,6 +520,7 @@ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
469
520
  // override whose justification was gitignored, and left `regate` with nothing
470
521
  // to re-evaluate on the one cell a human had disputed.
471
522
  ...findTraceFiles(runDir, scenarioId),
523
+ ...findTrajectoryFiles(runDir, scenarioId),
472
524
  ];
473
525
  if (files.length === 0)
474
526
  return;
package/dist/run.js CHANGED
@@ -2,18 +2,20 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { sourceHashes } from "./sources.js";
4
4
  import { judgeResemblesSubject } from "./grade.js";
5
- import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
5
+ import { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
6
6
  import { appendJournal } from "./journal.js";
7
7
  import { liftHeadline } from "./lift.js";
8
8
  import { runSeeded } from "./seeded.js";
9
9
  import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
10
10
  import { evaluateTraceGates } from "./trace-gates.js";
11
+ import { evaluateTrajectoryGates, serializeTrajectoryEvents, } from "./trajectory-gates.js";
11
12
  import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
12
13
  import { runPool } from "./scheduler.js";
13
14
  import { outcomesToResult } from "./reps.js";
14
15
  import { judgeOneRep } from "./regrade.js";
15
16
  import { runDeliveryCanary, canaryFailure } from "./canary.js";
16
17
  import { boundaryCells, stabilityNote } from "./stability.js";
18
+ import { aggregateMetrics } from "./comparison.js";
17
19
  /** Run one skill against one model: run scenarios, grade, score, persist. */
18
20
  export async function runSkillModel(opts) {
19
21
  const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
@@ -107,7 +109,9 @@ export async function runSkillModel(opts) {
107
109
  const grouped = scenarios.map(() => []);
108
110
  flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
109
111
  const scenarioResults = scenarios.map((scenario, si) => {
110
- const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
112
+ const threshold = scenario.critical
113
+ ? effectiveThreshold(undefined, scenario)
114
+ : scenario.passThreshold ?? opts.passThreshold ?? 0.5;
111
115
  return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
112
116
  });
113
117
  const ctx = scoreContextFor({ mode, partial }, spec);
@@ -152,6 +156,7 @@ export function hasEmptyAssistantTurn(transcript) {
152
156
  }
153
157
  /** Run ONE rep of a scenario in its own isolated workspace. */
154
158
  async function runRep(scenario, rep, repCount, ctx) {
159
+ const startedAt = performance.now();
155
160
  const { spec, judge, mode, runDir, now, log } = ctx;
156
161
  const repField = repCount > 1 ? { rep } : {};
157
162
  if (rep === 0) {
@@ -161,6 +166,7 @@ async function runRep(scenario, rep, repCount, ctx) {
161
166
  let ws = null;
162
167
  let transcript = "";
163
168
  let gatePrefix = null;
169
+ let infrastructureFailure = null;
164
170
  // Null until a seeded rep actually reaches its gates: a workspace-setup failure
165
171
  // produces no diff, and writing an empty artifact there would misreport "the
166
172
  // model changed nothing" for a rep that never ran.
@@ -170,12 +176,16 @@ async function runRep(scenario, rep, repCount, ctx) {
170
176
  ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
171
177
  }
172
178
  catch (e) {
173
- // A setup failure (e.g. missing fixture) is an objective FAIL, not an infra abort.
179
+ // A setup failure means the scenario never executed. It blocks release as
180
+ // infrastructure ERROR; it is not evidence of subject behavior.
174
181
  gatePrefix = e instanceof Error ? e.message : String(e);
182
+ infrastructureFailure = gatePrefix;
175
183
  transcript = `[workspace setup failed] ${gatePrefix}`;
176
184
  }
177
185
  let noResponse = false;
178
186
  let traces = [];
187
+ let events = [];
188
+ let eventErrors = [];
179
189
  let unobservablePaths = false;
180
190
  // The pre-run state, captured AFTER `createWorkspace` has applied the
181
191
  // fixture's `_staged/` and `_uncommitted/` trees. Those land after the
@@ -201,12 +211,15 @@ async function runRep(scenario, rep, repCount, ctx) {
201
211
  const r = await runSeeded(scenario, {
202
212
  skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
203
213
  specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
204
- trace: scenario.traceAssert ? { scenarioId: scenario.id, rep } : undefined,
214
+ trace: scenario.traceAssert || scenario.trajectoryAssert ? { scenarioId: scenario.id, rep } : undefined,
205
215
  });
206
216
  transcript = r.transcript;
207
217
  gatePrefix = r.gateFailure;
218
+ infrastructureFailure = r.gateError;
208
219
  stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
209
220
  traces = r.traces;
221
+ events = r.events;
222
+ eventErrors = r.eventErrors;
210
223
  }
211
224
  else {
212
225
  const req = {
@@ -217,18 +230,21 @@ async function runRep(scenario, rep, repCount, ctx) {
217
230
  : undefined,
218
231
  // Absolute before it reaches a child process running in a neutral cwd.
219
232
  extensions: scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)),
233
+ eventSources: scenario.eventSources,
220
234
  };
221
- if (scenario.traceAssert) {
235
+ if (scenario.traceAssert || scenario.trajectoryAssert) {
222
236
  // Missing required evidence is ERROR, never a silent fallback to the
223
237
  // unstructured path: a gate with nothing to read must not look like a
224
238
  // gate that passed.
225
239
  if (!ctx.adapter.runStructured) {
226
- throw new Error(`scenario \`${scenario.id}\` declares \`assert.trace\`, but the \`${ctx.adapter.name}\` adapter` +
227
- ` cannot produce execution traces — the gate would have no evidence to read.`);
240
+ throw new Error(`scenario \`${scenario.id}\` declares structured objective assertions, but the \`${ctx.adapter.name}\` adapter` +
241
+ ` cannot produce execution traces/events — the gate would have no evidence to read.`);
228
242
  }
229
243
  const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
230
244
  transcript = structured.transcript;
231
245
  traces = structured.traces;
246
+ events = structured.events ?? [];
247
+ eventErrors = structured.eventErrors ?? [];
232
248
  }
233
249
  else {
234
250
  transcript = await ctx.adapter.run(req);
@@ -279,34 +295,55 @@ async function runRep(scenario, rep, repCount, ctx) {
279
295
  // Objective evidence is persisted for every rep, pass or fail — a failing gate
280
296
  // is exactly when someone wants to read what the model actually did.
281
297
  let objective;
282
- if (scenario.traceAssert) {
283
- if (traces.length > 0) {
284
- writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
285
- }
286
- const merged = mergeTraces(traces);
287
- if (unobservablePaths) {
288
- gatePrefix = "objective: workspace changes could not be observed — `unchanged_paths` has no evidence to check";
289
- objective = { status: "ERROR", assertions: [] };
290
- }
291
- else if (merged === null) {
292
- // Declared a gate, produced no trace: that is broken infrastructure, and
293
- // grading it either way would be inventing a result.
294
- gatePrefix = "objective: no execution trace was produced — cannot evaluate assert.trace";
295
- objective = { status: "ERROR", assertions: [] };
298
+ if (scenario.traceAssert || scenario.trajectoryAssert) {
299
+ const assertionResults = [];
300
+ let status = "PASS";
301
+ let traceMeta = {};
302
+ let trajectoryMeta = {};
303
+ if (scenario.traceAssert) {
304
+ if (traces.length > 0) {
305
+ writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
306
+ }
307
+ const merged = mergeTraces(traces);
308
+ if (unobservablePaths) {
309
+ status = "ERROR";
310
+ assertionResults.push({ kind: "unchanged_path", status: "ERROR", detail: "workspace changes could not be observed" });
311
+ }
312
+ else if (merged === null) {
313
+ status = "ERROR";
314
+ assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: "no execution trace was produced" });
315
+ }
316
+ else {
317
+ const gate = evaluateTraceGates(scenario.traceAssert, merged);
318
+ status = gate.status;
319
+ assertionResults.push(...gate.assertions);
320
+ traceMeta = { trace_version: merged.trace_version, trace_sha256: merged.trace_sha256 };
321
+ }
296
322
  }
297
- else {
298
- const gate = evaluateTraceGates(scenario.traceAssert, merged);
299
- objective = {
300
- status: gate.status,
301
- trace_version: merged.trace_version,
302
- trace_sha256: merged.trace_sha256,
303
- assertions: gate.assertions,
304
- };
305
- if (gate.status === "FAIL") {
306
- // Set the same gatePrefix the seeded gates use, so a trace failure
307
- // short-circuits the judge through the path that already exists.
308
- gatePrefix = `objective: ${gate.assertions.filter((x) => x.status === "FAIL").map((x) => x.detail).join("; ")}`;
323
+ if (scenario.trajectoryAssert) {
324
+ if (events.length > 0) {
325
+ writeFileSync(trajectoryPath(runDir, scenario.id, mode, repSuffix), serializeTrajectoryEvents(events), "utf8");
326
+ }
327
+ if (eventErrors.length > 0) {
328
+ status = "ERROR";
329
+ assertionResults.push(...eventErrors.map((detail) => ({ kind: "trajectory_evidence", status: "ERROR", detail })));
309
330
  }
331
+ else if (events.length === 0) {
332
+ status = "ERROR";
333
+ assertionResults.push({ kind: "trajectory_evidence", status: "ERROR", detail: "no normalized workflow events were produced" });
334
+ }
335
+ else {
336
+ const gate = evaluateTrajectoryGates(scenario.trajectoryAssert, events);
337
+ if (gate.status === "ERROR" || (gate.status === "FAIL" && status === "PASS"))
338
+ status = gate.status;
339
+ assertionResults.push(...gate.assertions);
340
+ trajectoryMeta = { trajectory_version: gate.event_version, events_sha256: gate.events_sha256 };
341
+ }
342
+ }
343
+ objective = { status, ...traceMeta, ...trajectoryMeta, assertions: assertionResults };
344
+ if (status !== "PASS") {
345
+ const details = assertionResults.filter((result) => result.status === status).map((result) => result.detail);
346
+ gatePrefix = `objective: ${details.join("; ") || "structured evidence could not be evaluated"}`;
310
347
  }
311
348
  appendJournal(runDir, {
312
349
  event: "objective-result", ts: now(), id: scenario.id,
@@ -316,11 +353,17 @@ async function runRep(scenario, rep, repCount, ctx) {
316
353
  let verdict;
317
354
  let reason;
318
355
  let suspect = false;
356
+ let judgeCalls = 0;
319
357
  if (objective?.status === "ERROR") {
320
358
  verdict = "ERROR";
321
359
  reason = gatePrefix ?? "objective evidence missing";
322
360
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
323
361
  }
362
+ else if (infrastructureFailure) {
363
+ verdict = "ERROR";
364
+ reason = infrastructureFailure;
365
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
366
+ }
324
367
  else if (noResponse) {
325
368
  verdict = "ERROR";
326
369
  reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
@@ -340,9 +383,19 @@ async function runRep(scenario, rep, repCount, ctx) {
340
383
  verdict = o.verdict;
341
384
  reason = o.reason;
342
385
  suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
386
+ judgeCalls = 1;
343
387
  }
344
388
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
345
- return { verdict, reason, suspect, objective };
389
+ const subject = mergeTraces(traces)?.metrics;
390
+ return {
391
+ verdict, reason, suspect, objective,
392
+ metrics: {
393
+ wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
394
+ judge_calls: judgeCalls,
395
+ judge_rejudge_calls: 0,
396
+ ...(subject ? { subject } : {}),
397
+ },
398
+ };
346
399
  }
347
400
  finally {
348
401
  ws?.cleanup();
@@ -370,7 +423,7 @@ export function formatScorecard(summary, lift, stability) {
370
423
  const lines = [];
371
424
  lines.push(`── ${results.skill} · ${results.harness} · ${results.model} ──`);
372
425
  for (const s of results.scenarios) {
373
- const v = s.override ?? s.judge_verdict;
426
+ const v = s.override ?? (s.objective?.status === "ERROR" ? "ERROR" : s.objective?.status === "FAIL" ? "FAIL" : s.judge_verdict);
374
427
  const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : "?";
375
428
  const ov = s.override ? " (override)" : "";
376
429
  const susp = s.suspect ? " ⚠suspect" : "";
@@ -381,6 +434,14 @@ export function formatScorecard(summary, lift, stability) {
381
434
  const ship = g.ship ? "SHIP" : "NOT READY";
382
435
  const note = g.note ? ` (${g.note})` : "";
383
436
  lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
437
+ const metrics = aggregateMetrics(results.scenarios);
438
+ if (metrics.total_reps > 0) {
439
+ const tokens = metrics.input_tokens === null
440
+ ? "subject tokens unavailable"
441
+ : `subject tokens ${metrics.input_tokens} in / ${metrics.output_tokens ?? 0} out / ${metrics.cache_read_tokens ?? 0} cache-read`;
442
+ const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0} delegated, max concurrency ${metrics.max_concurrency ?? 0}`;
443
+ lines.push(` COST: ${tokens} (${metrics.subject_metrics_reps}/${metrics.total_reps} reps reported) · ${metrics.judge_calls} judge + ${metrics.judge_rejudge_calls} re-judge call(s) · ${metrics.wall_time_ms}ms · ${tools}`);
444
+ }
384
445
  // Lift is a statement about a skill-delivered run (green or force). On a red run
385
446
  // the caller may still have a lift in hand (a scored run exists in the same tag),
386
447
  // but printing it under a baseline scorecard reads as if the baseline itself
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Optional OS-containment seam.
3
+ *
4
+ * The current workspace implementation copies fixtures into temp directories;
5
+ * that is isolation for test data, NOT process/filesystem/network containment.
6
+ * No production backend is registered yet. A container/bwrap implementation can
7
+ * satisfy this interface without teaching scoring or trajectory gates about one
8
+ * particular sandbox runtime.
9
+ */
10
+ export type SandboxNetworkPolicy = "allow" | "deny";
11
+ export interface SandboxRequest {
12
+ sourceWorkspace: string;
13
+ network: SandboxNetworkPolicy;
14
+ }
15
+ export interface SandboxSession {
16
+ cwd: string;
17
+ captureDiff(): Promise<string>;
18
+ cleanup(): Promise<void>;
19
+ }
20
+ export interface SandboxBackend {
21
+ name: string;
22
+ /** `os` is a backend claim; `none` must never be presented as containment. */
23
+ containment: "os" | "none";
24
+ prepare(request: SandboxRequest): Promise<SandboxSession>;
25
+ }
26
+ export interface SandboxRunResult<T> {
27
+ value: T;
28
+ diff: string;
29
+ backend: string;
30
+ containment: SandboxBackend["containment"];
31
+ }
32
+ export declare function withSandbox<T>(backend: SandboxBackend, request: SandboxRequest, run: (session: SandboxSession) => Promise<T>): Promise<SandboxRunResult<T>>;
@@ -0,0 +1,12 @@
1
+ export async function withSandbox(backend, request, run) {
2
+ const session = await backend.prepare(request);
3
+ try {
4
+ const value = await run(session);
5
+ const diff = await session.captureDiff();
6
+ return { value, diff, backend: backend.name, containment: backend.containment };
7
+ }
8
+ finally {
9
+ await session.cleanup();
10
+ }
11
+ }
12
+ //# sourceMappingURL=sandbox.js.map
package/dist/score.d.ts CHANGED
@@ -22,12 +22,14 @@ export interface ScoreResult {
22
22
  criticalFails: number;
23
23
  bSeriesFails: number;
24
24
  suspectCount: number;
25
+ errorCount: number;
25
26
  note: string;
26
27
  }
27
28
  export declare function letterFor(pct: number): string;
28
29
  /**
29
30
  * Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
30
- * on verdict PASS; FAIL and ERROR both count against it. A `suspect` verdict (an
31
+ * on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
32
+ * infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
31
33
  * unresolved judge misfire) is excluded from both `passed` and `total` — it is
32
34
  * untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
33
35
  * until an author override resolves it. SHIP otherwise requires: enough total
package/dist/score.js CHANGED
@@ -11,7 +11,8 @@ export function letterFor(pct) {
11
11
  }
12
12
  /**
13
13
  * Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
14
- * on verdict PASS; FAIL and ERROR both count against it. A `suspect` verdict (an
14
+ * on verdict PASS; FAIL is behavioral, while ERROR/JUDGE-AMBIGUOUS are excluded as
15
+ * infrastructure/indeterminate and independently block SHIP. A `suspect` verdict (an
15
16
  * unresolved judge misfire) is excluded from both `passed` and `total` — it is
16
17
  * untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
17
18
  * until an author override resolves it. SHIP otherwise requires: enough total
@@ -25,11 +26,16 @@ export function score(verdicts, input) {
25
26
  let criticalFails = 0;
26
27
  let bSeriesFails = 0;
27
28
  let suspectCount = 0;
29
+ let errorCount = 0;
28
30
  for (const v of verdicts) {
29
31
  if (v.suspect) {
30
32
  suspectCount++;
31
33
  continue; // untrustworthy: neither pass nor fail
32
34
  }
35
+ if (v.verdict === "ERROR" || v.verdict === "JUDGE-AMBIGUOUS") {
36
+ errorCount++;
37
+ continue; // infrastructure/indeterminate: neither behavioral pass nor fail
38
+ }
33
39
  total++;
34
40
  if (v.verdict === "PASS") {
35
41
  passed++;
@@ -42,21 +48,30 @@ export function score(verdicts, input) {
42
48
  }
43
49
  const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
44
50
  const letter = letterFor(pct);
45
- const ship = total >= shipBar.total &&
51
+ const validBar = Number.isInteger(shipBar.total) && shipBar.total >= 1 && Number.isInteger(shipBar.min_pass) && shipBar.min_pass >= 1 && shipBar.min_pass <= shipBar.total;
52
+ const ship = validBar &&
53
+ total >= shipBar.total &&
46
54
  passed >= shipBar.min_pass &&
47
55
  (!shipBar.no_critical_fail || criticalFails === 0) &&
48
56
  bSeriesFails === 0 &&
49
- suspectCount === 0;
57
+ suspectCount === 0 &&
58
+ errorCount === 0;
50
59
  let note = "";
51
- if (suspectCount > 0) {
60
+ if (!validBar) {
61
+ note = "invalid ship bar: total/min_pass must be positive integers with min_pass <= total";
62
+ }
63
+ else if (suspectCount > 0) {
52
64
  note = `${suspectCount} suspect: re-judge/resolve`;
53
65
  }
66
+ else if (errorCount > 0) {
67
+ note = `${errorCount} infrastructure error${errorCount === 1 ? "" : "s"}: retry/repair evidence`;
68
+ }
54
69
  else if (criticalFails > 0) {
55
70
  note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
56
71
  }
57
72
  else if (bSeriesFails > 0) {
58
73
  note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
59
74
  }
60
- return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, note };
75
+ return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, errorCount, note };
61
76
  }
62
77
  //# sourceMappingURL=score.js.map
package/dist/seeded.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Scenario } from "./spec.js";
2
2
  import type { ExecutionTraceV1 } from "./capture-trace-types.js";
3
+ import type { TrajectoryEventV1 } from "./trajectory-gates.js";
3
4
  import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
4
5
  import { type ExecResult } from "./util/exec.js";
5
6
  interface SeededOpts {
@@ -42,9 +43,14 @@ export type VitestRun = ExecResult;
42
43
  export interface SeededOutcome {
43
44
  transcript: string;
44
45
  gateFailure: string | null;
46
+ gateError: string | null;
45
47
  diff: string;
46
- /** One per turn; empty unless the scenario declared `assert.trace`. */
48
+ /** One per turn; empty unless structured execution was requested. */
47
49
  traces: ExecutionTraceV1[];
50
+ /** Adapter-neutral workflow/tool events for assert.trajectory. */
51
+ events: TrajectoryEventV1[];
52
+ /** Required native sources that could not be read or normalized. */
53
+ eventErrors: string[];
48
54
  }
49
55
  /**
50
56
  * The added/removed lines of a unified diff — what the model actually *changed*,