@skill-harness/core 0.7.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.
Files changed (44) hide show
  1. package/dist/adapters/types.d.ts +8 -0
  2. package/dist/adjudication.d.ts +1 -0
  3. package/dist/adjudication.js +26 -4
  4. package/dist/capture-trace-types.d.ts +19 -0
  5. package/dist/comparison.d.ts +96 -0
  6. package/dist/comparison.js +183 -0
  7. package/dist/execution-trace.js +93 -3
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +4 -0
  10. package/dist/lint.d.ts +7 -0
  11. package/dist/lint.js +14 -2
  12. package/dist/regate.d.ts +1 -1
  13. package/dist/regate.js +94 -21
  14. package/dist/regrade.d.ts +4 -0
  15. package/dist/regrade.js +29 -5
  16. package/dist/report.d.ts +7 -0
  17. package/dist/report.js +6 -0
  18. package/dist/reps.d.ts +8 -1
  19. package/dist/reps.js +47 -13
  20. package/dist/rescore.js +2 -2
  21. package/dist/restamp.d.ts +68 -0
  22. package/dist/restamp.js +194 -0
  23. package/dist/results.d.ts +28 -0
  24. package/dist/results.js +56 -4
  25. package/dist/run.js +96 -35
  26. package/dist/sandbox.d.ts +32 -0
  27. package/dist/sandbox.js +12 -0
  28. package/dist/score.d.ts +3 -1
  29. package/dist/score.js +20 -5
  30. package/dist/seeded.d.ts +7 -1
  31. package/dist/seeded.js +29 -8
  32. package/dist/sources.d.ts +50 -0
  33. package/dist/sources.js +186 -7
  34. package/dist/spec.d.ts +15 -0
  35. package/dist/spec.js +53 -1
  36. package/dist/stability.js +26 -4
  37. package/dist/trace-gates.d.ts +2 -1
  38. package/dist/trace-gates.js +4 -1
  39. package/dist/trajectory-gates.d.ts +137 -0
  40. package/dist/trajectory-gates.js +592 -0
  41. package/package.json +5 -3
  42. package/schemas/results-v2.schema.json +133 -0
  43. package/schemas/specification-v1.schema.json +217 -0
  44. package/schemas/trajectory-event-v1.schema.json +61 -0
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { loadSpec } from "./spec.js";
7
+ import { resultsPath } from "./results.js";
8
+ import { enumerateRunDirs } from "./lint.js";
9
+ import { promptDocDigest, SKILL_KEY, SKILL_PROMPT_KEY, PROMPT_PREFIX, UNREADABLE, } from "./sources.js";
10
+ /**
11
+ * The `source_hashes` map of a raw-loaded results doc, or null when the doc has none.
12
+ * Read from the RAW yaml rather than through `readResults` on purpose: this rewrites the
13
+ * file, and `readResults` migrates schema 1 → 2 in memory, so writing its output back
14
+ * would silently upgrade the schema of records nobody asked to touch.
15
+ */
16
+ function rawHashes(doc) {
17
+ if (!doc || typeof doc !== "object")
18
+ return null;
19
+ const h = doc.source_hashes;
20
+ if (!h || typeof h !== "object" || Array.isArray(h))
21
+ return null;
22
+ return h;
23
+ }
24
+ /**
25
+ * `git show <ref>:<path>` as BYTES, or null when the ref, the repo, or the path isn't there.
26
+ *
27
+ * `execFileSync` with a buffer encoding rather than the shared `exec` helper: that one
28
+ * accumulates stdout as decoded strings, so a multi-byte character landing on a chunk
29
+ * boundary would come back with a replacement character and hash to something the run
30
+ * never recorded. This comparison is byte-exact by definition, so it reads bytes.
31
+ */
32
+ function bytesAtRef(abs, ref) {
33
+ const cwd = dirname(abs);
34
+ try {
35
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
36
+ const rel = relative(top, abs).split("\\").join("/");
37
+ return execFileSync("git", ["show", `${ref}:${rel}`], { cwd, maxBuffer: 64 * 1024 * 1024 });
38
+ }
39
+ catch {
40
+ return null; // no git, no repo, no such ref, or the file did not exist there
41
+ }
42
+ }
43
+ /**
44
+ * The bytes a run is claimed to have measured: `<ref>`'s copy, or the working tree's when
45
+ * no ref was given. Null when they cannot be read at all, which proves nothing and so
46
+ * upgrades nothing.
47
+ */
48
+ function measuredBytes(abs, from) {
49
+ if (from !== undefined)
50
+ return bytesAtRef(abs, from);
51
+ try {
52
+ return readFileSync(abs);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Fail loudly when `--from` names something git cannot resolve.
60
+ *
61
+ * Without this the whole migration degrades into "0 upgraded, everything left alone",
62
+ * which is indistinguishable from a corpus that genuinely cannot be proven — measured on
63
+ * a clone whose only local branch was the feature branch, where `--from main` silently
64
+ * did nothing and read as the tool being broken. A ref the operator got wrong is their
65
+ * mistake to see, not one to absorb.
66
+ */
67
+ function assertRefResolves(dir, ref) {
68
+ try {
69
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: dir, stdio: "ignore" });
70
+ }
71
+ catch {
72
+ throw new Error(`--from \`${ref}\` does not resolve to a commit in the repo holding ${dir} — ` +
73
+ `check the ref exists locally (a fresh clone often has no local \`main\`; try \`origin/main\`)`);
74
+ }
75
+ }
76
+ /**
77
+ * Write via a temp file in the same directory, then rename.
78
+ *
79
+ * `rename(2)` is atomic within a filesystem, so a reader sees the old file or the new one
80
+ * and never a half-written one. It matters more here than in the writers that produce a
81
+ * run: this loops over an entire published corpus rewriting records that already hold
82
+ * measurements nobody can reproduce without spending model tokens, so an interrupt or a
83
+ * full disk partway through would truncate exactly the artifacts the command exists to
84
+ * preserve. The temp file is `.tmp`-suffixed beside the target rather than in the system
85
+ * temp dir, because a cross-device rename is not atomic — and is not even permitted.
86
+ */
87
+ function writeAtomically(path, text) {
88
+ const tmp = `${path}.restamp.tmp`;
89
+ writeFileSync(tmp, text, "utf8");
90
+ try {
91
+ renameSync(tmp, path);
92
+ }
93
+ catch (e) {
94
+ try {
95
+ rmSync(tmp, { force: true });
96
+ }
97
+ catch { /* the rename failure is what matters */ }
98
+ throw e;
99
+ }
100
+ }
101
+ /** Upgrade every provable record under one skill's tests/results. */
102
+ export function restampSkill(skillDir, opts = {}) {
103
+ const report = { runs: 0, upgraded: 0, unprovable: 0, unchanged: 0, partial: 0, added: [] };
104
+ const specPath = join(skillDir, "tests", "specification.yaml");
105
+ const spec = loadSpec(specPath);
106
+ const specDir = dirname(specPath);
107
+ // Only `system_prompt_file` paths, resolved from the CURRENT spec. The other
108
+ // path-shaped keys (`post_test`, `env.extensions`) name code, not prompt documents:
109
+ // every byte of those reaches the run, so they keep hashing raw and are left alone.
110
+ const promptDocs = new Map([[SKILL_KEY, resolve(skillDir, "SKILL.md")]]);
111
+ for (const s of spec.scenarios) {
112
+ if (s.systemPromptFile)
113
+ promptDocs.set(s.systemPromptFile, resolve(specDir, s.systemPromptFile));
114
+ }
115
+ if (opts.from !== undefined)
116
+ assertRefResolves(skillDir, opts.from);
117
+ // Resolved once per document, not once per run: with `--from`, each resolution is two
118
+ // git invocations, and a corpus of 140 runs would otherwise pay for them 140 times over.
119
+ const provable = new Map();
120
+ for (const [legacyKey, abs] of promptDocs) {
121
+ const measured = measuredBytes(abs, opts.from);
122
+ let currentPrompt = null;
123
+ try {
124
+ currentPrompt = promptDocDigest(readFileSync(abs, "utf8"));
125
+ }
126
+ catch {
127
+ currentPrompt = null;
128
+ }
129
+ provable.set(legacyKey, {
130
+ measuredRaw: measured === null ? null : createHash("sha256").update(measured).digest("hex"),
131
+ measuredPrompt: measured === null ? null : promptDocDigest(measured.toString("utf8")),
132
+ currentPrompt,
133
+ });
134
+ }
135
+ for (const runDir of enumerateRunDirs(join(skillDir, "tests", "results"))) {
136
+ report.runs++;
137
+ let doc;
138
+ try {
139
+ doc = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
140
+ }
141
+ catch {
142
+ // Unreadable/malformed → lint already reports it; never rewrite what we can't read.
143
+ // Still bucketed: every record counted in `runs` has to be accounted for somewhere.
144
+ report.unchanged++;
145
+ continue;
146
+ }
147
+ const hashes = rawHashes(doc);
148
+ if (!hashes) {
149
+ report.unchanged++; // predates source_hashes → nothing to upgrade from
150
+ continue;
151
+ }
152
+ let blocked = false;
153
+ const additions = [];
154
+ for (const legacyKey of promptDocs.keys()) {
155
+ const recorded = hashes[legacyKey];
156
+ if (recorded === undefined || recorded === UNREADABLE)
157
+ continue; // never measured → nothing proven
158
+ const upgradedKey = legacyKey === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + legacyKey;
159
+ if (hashes[upgradedKey] !== undefined)
160
+ continue; // already upgraded
161
+ const p = provable.get(legacyKey);
162
+ if (p.currentPrompt === null)
163
+ continue; // the file is gone; lint reports that itself
164
+ // Two steps, and both are required. First: this run measured the reference bytes —
165
+ // otherwise the reference says nothing about THIS run. Second: those bytes and
166
+ // today's carry the same model-visible text — otherwise what the model reads has
167
+ // genuinely moved and only a re-run can speak for it.
168
+ if (p.measuredRaw === null || recorded !== p.measuredRaw || p.measuredPrompt !== p.currentPrompt) {
169
+ blocked = true;
170
+ continue;
171
+ }
172
+ additions.push([upgradedKey, p.currentPrompt]);
173
+ }
174
+ if (additions.length === 0) {
175
+ // Exclusive: a record that gained nothing is `unprovable` only if something
176
+ // actually blocked it, and `unchanged` otherwise.
177
+ if (blocked)
178
+ report.unprovable++;
179
+ else
180
+ report.unchanged++;
181
+ continue;
182
+ }
183
+ for (const [k, v] of additions) {
184
+ hashes[k] = v;
185
+ report.added.push(`${runDir}: ${k}`);
186
+ }
187
+ writeAtomically(resultsPath(runDir), yaml.dump(doc, { lineWidth: 100 }));
188
+ report.upgraded++;
189
+ if (blocked)
190
+ report.partial++;
191
+ }
192
+ return report;
193
+ }
194
+ //# sourceMappingURL=restamp.js.map
package/dist/results.d.ts CHANGED
@@ -2,6 +2,23 @@ import { type ModelRef } from "./adapters/types.js";
2
2
  import { type ScenarioVerdict } from "./score.js";
3
3
  import type { Verdict } from "./score.js";
4
4
  import type { ShipBar, Scenario } from "./spec.js";
5
+ export interface ScenarioMetrics {
6
+ wall_time_ms: number;
7
+ judge_calls: number;
8
+ judge_rejudge_calls: number;
9
+ subject_metrics_reps: number;
10
+ total_reps: number;
11
+ input_tokens?: number;
12
+ output_tokens?: number;
13
+ cache_read_tokens?: number;
14
+ cache_write_tokens?: number;
15
+ subject_cost_usd?: number;
16
+ tool_calls?: number;
17
+ delegated_children?: number;
18
+ max_concurrency?: number;
19
+ }
20
+ /** Merge bookkeeping from a later judge/gate pass without duplicating subject usage. */
21
+ export declare function mergeScenarioMetrics(prior: ScenarioMetrics | undefined, fresh: ScenarioMetrics | undefined): ScenarioMetrics | undefined;
5
22
  export interface ScenarioResult {
6
23
  id: string;
7
24
  judge_verdict: Verdict;
@@ -14,6 +31,8 @@ export interface ScenarioResult {
14
31
  clean?: number;
15
32
  flakiness?: number;
16
33
  pass_threshold?: number;
34
+ /** Cost/latency counters. Separate from verdicts: cheaper failure is still failure. */
35
+ metrics?: ScenarioMetrics;
17
36
  /**
18
37
  * Objective trace-gate evidence. ADDITIVE and optional.
19
38
  *
@@ -59,6 +78,11 @@ export interface ObjectiveResult {
59
78
  status: "PASS" | "FAIL" | "ERROR";
60
79
  trace_version?: number;
61
80
  trace_sha256?: string;
81
+ trajectory_version?: string;
82
+ events_sha256?: string;
83
+ /** Per-repetition hashes retained when an aggregate has more than one rep. */
84
+ rep_events_sha256?: string[];
85
+ rep_trace_sha256?: string[];
62
86
  assertions: {
63
87
  kind: string;
64
88
  status: "PASS" | "FAIL" | "ERROR";
@@ -327,6 +351,10 @@ export declare function rebuildScenarioResult(fresh: ScenarioResult, prior: Scen
327
351
  * traces append without a wrapper.
328
352
  */
329
353
  export declare function tracePath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
354
+ /** Saved adapter-neutral workflow events for one scenario rep. */
355
+ export declare function trajectoryPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
356
+ /** A scenario's normalized-event files, sorted (plain first, then numeric rep). */
357
+ export declare function findTrajectoryFiles(runDir: string, scenarioId: string, mode?: string): string[];
330
358
  /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
331
359
  export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
332
360
  /** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
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>>;