@skill-harness/core 0.29.0 → 0.31.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 (59) hide show
  1. package/README.md +16 -2
  2. package/dist/adapters/types.d.ts +2 -0
  3. package/dist/authoring-preflight.d.ts +22 -0
  4. package/dist/authoring-preflight.js +111 -0
  5. package/dist/authoring.d.ts +96 -0
  6. package/dist/authoring.js +451 -0
  7. package/dist/calibration.d.ts +138 -0
  8. package/dist/calibration.js +352 -0
  9. package/dist/canary.d.ts +28 -26
  10. package/dist/canary.js +109 -92
  11. package/dist/command-cost.d.ts +1 -1
  12. package/dist/command-cost.js +2 -0
  13. package/dist/diagnose.d.ts +17 -0
  14. package/dist/diagnose.js +70 -0
  15. package/dist/execution-trace.d.ts +2 -1
  16. package/dist/execution-trace.js +4 -3
  17. package/dist/grade.d.ts +8 -0
  18. package/dist/grade.js +37 -8
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +8 -0
  21. package/dist/journal.d.ts +3 -0
  22. package/dist/judge-evidence.d.ts +40 -0
  23. package/dist/judge-evidence.js +120 -0
  24. package/dist/judge-references.d.ts +8 -0
  25. package/dist/judge-references.js +34 -0
  26. package/dist/lint.d.ts +1 -1
  27. package/dist/lint.js +22 -0
  28. package/dist/regate.js +21 -3
  29. package/dist/regrade.d.ts +10 -1
  30. package/dist/regrade.js +146 -75
  31. package/dist/rescore.js +1 -0
  32. package/dist/resource-monitor.d.ts +3 -0
  33. package/dist/resource-monitor.js +72 -0
  34. package/dist/results.d.ts +16 -4
  35. package/dist/results.js +10 -0
  36. package/dist/run.d.ts +2 -2
  37. package/dist/run.js +147 -116
  38. package/dist/saved-run.d.ts +15 -0
  39. package/dist/saved-run.js +114 -0
  40. package/dist/scheduler.d.ts +3 -5
  41. package/dist/scheduler.js +18 -6
  42. package/dist/seeded.js +1 -0
  43. package/dist/sources.d.ts +4 -1
  44. package/dist/sources.js +44 -40
  45. package/dist/spec.d.ts +8 -0
  46. package/dist/spec.js +27 -0
  47. package/dist/trace-gates.d.ts +2 -0
  48. package/dist/trace-gates.js +13 -3
  49. package/dist/util/exec.d.ts +5 -0
  50. package/dist/util/exec.js +16 -32
  51. package/dist/util/fixture-inventory.d.ts +19 -0
  52. package/dist/util/fixture-inventory.js +68 -0
  53. package/dist/util/process.d.ts +78 -0
  54. package/dist/util/process.js +394 -0
  55. package/dist/workspace.js +13 -29
  56. package/package.json +1 -1
  57. package/schemas/results-v2.schema.json +10 -0
  58. package/schemas/results-v3.schema.json +10 -0
  59. package/schemas/specification-v1.schema.json +21 -0
package/dist/run.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { sourceHashes, sourceHashRoots } from "./sources.js";
4
- import { judgeResemblesSubject } from "./grade.js";
4
+ import { judgeResemblesSubject, resolveJudgeReferences } from "./grade.js";
5
+ import { inspectEvalAuthoring } from "./authoring-preflight.js";
5
6
  import { NONE_ARM, seedArmDefinitions } from "./arms.js";
6
7
  import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
7
8
  import { appendJournal } from "./journal.js";
8
9
  import { liftHeadline } from "./lift.js";
9
10
  import { evaluateOutputGates, hasOutputGates, runSeeded } from "./seeded.js";
10
11
  import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
12
+ import { executionFacts, transcriptResponses } from "./judge-evidence.js";
11
13
  import { evaluateTraceGates } from "./trace-gates.js";
12
14
  import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
13
15
  import { runPool } from "./scheduler.js";
@@ -17,6 +19,8 @@ import { runDeliveryCanary, canaryFailure } from "./canary.js";
17
19
  import { boundaryCells, stabilityNote } from "./stability.js";
18
20
  import { aggregateMetrics } from "./metrics.js";
19
21
  import { providerFailureFromTranscript, executionFailureFromTranscript, withExecutionFailure } from "./provider-failure.js";
22
+ import { withResourceMonitoring } from "./resource-monitor.js";
23
+ import { throwIfProcessAborted, isProcessControlError } from "./util/process.js";
20
24
  /**
21
25
  * The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
22
26
  * `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
@@ -52,7 +56,10 @@ export async function runSkillModel(opts) {
52
56
  // --only: validate against the spec BEFORE spending anything — a typo'd id must not
53
57
  // silently run zero scenarios and report success.
54
58
  let scenarios = spec.scenarios;
55
- const partial = Boolean(opts.only && opts.only.length > 0);
59
+ const partial = opts.only !== undefined;
60
+ if (partial && (!opts.only.length || opts.only.some(id => !id.trim() || id !== id.trim()) || new Set(opts.only).size !== opts.only.length)) {
61
+ throw new Error("--only requires a nonempty, unique scenario ID list");
62
+ }
56
63
  if (partial) {
57
64
  const known = new Set(spec.scenarios.map((s) => s.id));
58
65
  const unknown = opts.only.filter((id) => !known.has(id));
@@ -63,6 +70,11 @@ export async function runSkillModel(opts) {
63
70
  scenarios = spec.scenarios.filter((s) => wanted.has(s.id));
64
71
  log(` --only ${opts.only.join(",")} — partial run, will not be ship-graded`);
65
72
  }
73
+ const authoringErrors = inspectEvalAuthoring({ ...spec, scenarios }).filter(f => f.severity === "error");
74
+ if (authoringErrors.length)
75
+ throw new Error(authoringErrors.map(f => `${f.scenario}: ${f.message}`).join("\n"));
76
+ const judgeReferences = Object.fromEntries(scenarios.map(s => [s.id, resolveJudgeReferences(s, dirname(opts.specPath))]));
77
+ const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona, judgeReferences });
66
78
  if (judgeResemblesSubject(judge, model)) {
67
79
  log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
68
80
  `(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
@@ -71,119 +83,124 @@ export async function runSkillModel(opts) {
71
83
  const runDir = runDirFor(dirname(opts.testsDir ?? join(skillDir, "tests")), adapter.name, model, timestamp, arm.name);
72
84
  mkdirSync(runDir, { recursive: true });
73
85
  ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
74
- // Which harness CLI delivered the skill, asked once per run and recorded with the
75
- // numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
76
- // measures, and the incident was invisible in the artifacts because nothing wrote
77
- // this down. Never fatal: `null` means the adapter couldn't say.
78
- const harnessCliVersion = (await adapter.version?.()) ?? null;
79
- appendJournal(runDir, {
80
- event: "run-started", ts: now(),
81
- skill: spec.skill, harness: adapter.name, model: opts.modelToken,
82
- harness_cli_version: harnessCliVersion,
83
- judge: { provider: judge.provider, model: judge.model },
84
- mode, label: opts.label ?? null,
85
- });
86
- // The canary spends one probe before the wave, so a run that isn't measuring the
87
- // skill dies for the price of a rep instead of producing a plausible scorecard.
88
- // Green only: red delivers nothing by design, and force delivers through the
89
- // system prompt, which needs no probe.
90
- let canaryStatus = null;
91
- if (opts.canary && mode !== "green") {
92
- // Ignoring a flag silently is a small version of the bug this whole feature is
93
- // about. Say it, and say why it isn't needed.
94
- log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
95
- }
96
- if (opts.canary && mode === "green") {
97
- const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
98
- let canary;
99
- try {
100
- canary = await runDeliveryCanary({
101
- adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
102
- });
103
- }
104
- finally {
105
- probeCwd.cleanup();
106
- }
86
+ return withResourceMonitoring(runDir, async () => {
87
+ throwIfProcessAborted();
88
+ // Which harness CLI delivered the skill, asked once per run and recorded with the
89
+ // numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
90
+ // measures, and the incident was invisible in the artifacts because nothing wrote
91
+ // this down. Never fatal: `null` means the adapter couldn't say.
92
+ const harnessCliVersion = (await adapter.version?.()) ?? null;
107
93
  appendJournal(runDir, {
108
- event: "delivery-canary", ts: now(),
109
- status: canary.status, anchor: canary.anchor, detail: canary.detail,
94
+ event: "run-started", ts: now(),
95
+ skill: spec.skill, harness: adapter.name, model: opts.modelToken,
96
+ harness_cli_version: harnessCliVersion,
97
+ judge: { provider: judge.provider, model: judge.model },
98
+ mode, label: opts.label ?? null,
110
99
  });
111
- if (canary.status === "fail")
112
- throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
113
- if (canary.status === "skipped") {
114
- // Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
115
- // "this run's delivery was verified" has to survive a commit — including
116
- // when it is the claim that it wasn't.
117
- canaryStatus = "skipped";
118
- log(` ⚠ delivery canary skipped — ${canary.detail}`);
100
+ // One optional instructed loading probe can stop a wave whose delivery check
101
+ // is inconclusive. It does not measure autonomous selection in later scenarios.
102
+ // Green only: red delivers nothing by design, and force delivers through the
103
+ // system prompt, which needs no probe.
104
+ let canaryStatus = null;
105
+ let canaryObservations;
106
+ if (opts.canary && mode !== "green") {
107
+ // Ignoring a flag silently is a small version of the bug this whole feature is
108
+ // about. Say it, and say why it isn't needed.
109
+ log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
119
110
  }
120
- else {
121
- canaryStatus = "pass";
122
- log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
111
+ if (opts.canary && mode === "green") {
112
+ const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
113
+ let canary;
114
+ try {
115
+ canary = await runDeliveryCanary({
116
+ adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
117
+ });
118
+ }
119
+ finally {
120
+ probeCwd.cleanup();
121
+ }
122
+ appendJournal(runDir, {
123
+ event: "delivery-canary", ts: now(),
124
+ status: canary.status, anchor: canary.anchor, detail: canary.detail,
125
+ observations: canary.observations, probeCalls: canary.probeCalls,
126
+ });
127
+ canaryObservations = { ...canary.observations, probeCalls: canary.probeCalls };
128
+ if (canary.status === "fail")
129
+ throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
130
+ if (canary.status === "skipped") {
131
+ // Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
132
+ // requested delivery observation has to survive a commit, including a skip.
133
+ canaryStatus = "skipped";
134
+ log(` ⚠ delivery canary skipped — ${canary.detail}`);
135
+ }
136
+ else {
137
+ canaryStatus = "pass";
138
+ log(` ✓ delivery canary — ${canary.detail}`);
139
+ }
123
140
  }
124
- }
125
- // scenario × rep tasks; runPool preserves input order so we can slice per scenario.
126
- const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
127
- const owners = [];
128
- const tasks = [];
129
- const armDefinitions = { count: 0 };
130
- scenarios.forEach((scenario, si) => {
131
- for (let k = 0; k < repCounts[si]; k++) {
132
- const rep = k;
133
- const total = repCounts[si];
134
- owners.push(si);
135
- tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions }));
141
+ // scenario × rep tasks; runPool preserves input order so we can slice per scenario.
142
+ const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
143
+ const owners = [];
144
+ const tasks = [];
145
+ const armDefinitions = { count: 0 };
146
+ scenarios.forEach((scenario, si) => {
147
+ for (let k = 0; k < repCounts[si]; k++) {
148
+ const rep = k;
149
+ const total = repCounts[si];
150
+ owners.push(si);
151
+ tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions, judgeReferences }));
152
+ }
153
+ });
154
+ const flat = await runPool(tasks, opts.concurrency ?? 1);
155
+ const grouped = scenarios.map(() => []);
156
+ flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
157
+ const scenarioResults = scenarios.map((scenario, si) => {
158
+ const threshold = scenario.critical
159
+ ? effectiveThreshold(undefined, scenario)
160
+ : scenario.passThreshold ?? opts.passThreshold ?? 0.5;
161
+ return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
162
+ });
163
+ const ctx = scoreContextFor({ mode, partial }, spec);
164
+ const results = writeResults(runDir, {
165
+ skill: spec.skill,
166
+ harness: adapter.name,
167
+ harness_cli_version: harnessCliVersion ?? undefined,
168
+ delivery_canary: canaryStatus ?? undefined,
169
+ delivery_observations: canaryObservations,
170
+ model: opts.modelToken,
171
+ judge: { provider: judge.provider, model: judge.model },
172
+ timestamp,
173
+ label: opts.label ?? null,
174
+ mode,
175
+ ...(partial ? { partial: true } : {}),
176
+ // Only the scenarios this run actually measured: a --only run must not claim
177
+ // coverage of scenarios it skipped.
178
+ source_hashes: hashes,
179
+ ...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
180
+ scenarios: scenarioResults,
181
+ ...(arm.name === NONE_ARM.name ? {} : {
182
+ arm: {
183
+ name: arm.name,
184
+ extensions: arm.extensions,
185
+ definitions: armDefinitions.count,
186
+ ledger_events: countLedgerEvents(runDir),
187
+ // The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
188
+ // being measured (grant, max depth), so leaving it out made two runs at
189
+ // different settings byte-identical here and inside the same `+<arm>`
190
+ // tag — `stability` then reads the verdict difference between two
191
+ // conditions as one lineage flipping. The substituted form would be the
192
+ // opposite error: it embeds this run's temp path, so re-running the SAME
193
+ // condition would record two different-looking arms.
194
+ env: arm.env,
195
+ },
196
+ }),
197
+ }, ctx);
198
+ if (ctx) {
199
+ const g = results.effective_grade;
200
+ appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
136
201
  }
202
+ return { runDir, results };
137
203
  });
138
- const flat = await runPool(tasks, opts.concurrency ?? 1);
139
- const grouped = scenarios.map(() => []);
140
- flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
141
- const scenarioResults = scenarios.map((scenario, si) => {
142
- const threshold = scenario.critical
143
- ? effectiveThreshold(undefined, scenario)
144
- : scenario.passThreshold ?? opts.passThreshold ?? 0.5;
145
- return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
146
- });
147
- const ctx = scoreContextFor({ mode, partial }, spec);
148
- const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
149
- const results = writeResults(runDir, {
150
- skill: spec.skill,
151
- harness: adapter.name,
152
- harness_cli_version: harnessCliVersion ?? undefined,
153
- delivery_canary: canaryStatus ?? undefined,
154
- model: opts.modelToken,
155
- judge: { provider: judge.provider, model: judge.model },
156
- timestamp,
157
- label: opts.label ?? null,
158
- mode,
159
- ...(partial ? { partial: true } : {}),
160
- // Only the scenarios this run actually measured: a --only run must not claim
161
- // coverage of scenarios it skipped.
162
- source_hashes: hashes,
163
- ...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
164
- scenarios: scenarioResults,
165
- ...(arm.name === NONE_ARM.name ? {} : {
166
- arm: {
167
- name: arm.name,
168
- extensions: arm.extensions,
169
- definitions: armDefinitions.count,
170
- ledger_events: countLedgerEvents(runDir),
171
- // The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
172
- // being measured (grant, max depth), so leaving it out made two runs at
173
- // different settings byte-identical here and inside the same `+<arm>`
174
- // tag — `stability` then reads the verdict difference between two
175
- // conditions as one lineage flipping. The substituted form would be the
176
- // opposite error: it embeds this run's temp path, so re-running the SAME
177
- // condition would record two different-looking arms.
178
- env: arm.env,
179
- },
180
- }),
181
- }, ctx);
182
- if (ctx) {
183
- const g = results.effective_grade;
184
- appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
185
- }
186
- return { runDir, results };
187
204
  }
188
205
  /**
189
206
  * True when any assistant turn in a transcript is blank — the shape a harness timeout
@@ -282,6 +299,7 @@ async function runRep(scenario, rep, repCount, ctx) {
282
299
  // it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
283
300
  // An adapter that THREW is the same class of event and takes the same retry.
284
301
  for (let attempt = 0; attempt < 2; attempt++) {
302
+ throwIfProcessAborted();
285
303
  if (attempt > 0) {
286
304
  const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
287
305
  appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
@@ -332,6 +350,7 @@ async function runRep(scenario, rep, repCount, ctx) {
332
350
  else {
333
351
  const req = {
334
352
  skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
353
+ tools: scenario.tools,
335
354
  // resolved like fixtures: relative to the spec's dir
336
355
  systemPromptFile: scenario.systemPromptFile
337
356
  ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
@@ -361,6 +380,9 @@ async function runRep(scenario, rep, repCount, ctx) {
361
380
  }
362
381
  }
363
382
  catch (e) {
383
+ if (isProcessControlError(e))
384
+ throw e;
385
+ throwIfProcessAborted();
364
386
  // An adapter that THREW is one rep's infrastructure failure, not the
365
387
  // wave's. `runPool` is fail-fast and `runRep` had only a `finally`, so
366
388
  // a single pi crash or `PI_TIMEOUT_MS` timeout unwound the whole run:
@@ -461,23 +483,26 @@ async function runRep(scenario, rep, repCount, ctx) {
461
483
  // timeout that caused it, and it is that text, not the real reason, that would
462
484
  // reach the results record. The verdict is ERROR either way.
463
485
  let objective;
486
+ if (traces.length > 0)
487
+ writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
488
+ const execution = executionFacts(traces, {
489
+ scenarioId: scenario.id, mode, repetition: rep, turns: scenario.turns.length,
490
+ subject: ctx.model, responses: transcriptResponses(transcript, scenario),
491
+ }, { source: "live" });
464
492
  if ((scenario.traceAssert || hasOutputGates(scenario)) && !adapterFailure && !noResponse && !infrastructureFailure) {
465
493
  const assertionResults = [...outputGate.assertions];
466
494
  let status = outputGate.status;
467
495
  let traceMeta = {};
468
496
  const outputMeta = outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {};
469
497
  if (scenario.traceAssert) {
470
- if (traces.length > 0) {
471
- writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
472
- }
473
- const merged = mergeTraces(traces);
498
+ const merged = execution.status === "invalid" ? null : mergeTraces(traces);
474
499
  if (unobservablePaths) {
475
500
  status = "ERROR";
476
501
  assertionResults.push({ kind: "unchanged_path", status: "ERROR", detail: "workspace changes could not be observed" });
477
502
  }
478
503
  else if (merged === null) {
479
504
  status = "ERROR";
480
- assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: "no execution trace was produced" });
505
+ assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: execution.status === "invalid" ? execution.reason : "no execution trace was produced" });
481
506
  }
482
507
  else {
483
508
  const gate = evaluateTraceGates(scenario.traceAssert, merged);
@@ -534,6 +559,7 @@ async function runRep(scenario, rep, repCount, ctx) {
534
559
  const o = await judgeOneRep({
535
560
  runDir, spec, scenario, transcript, adapter: ctx.adapter, judge,
536
561
  specDir: dirname(ctx.specPath), mode, rep: repCount > 1 ? rep : undefined, now,
562
+ execution, objective, references: ctx.judgeReferences[scenario.id],
537
563
  });
538
564
  verdict = o.verdict;
539
565
  reason = o.reason;
@@ -542,7 +568,7 @@ async function runRep(scenario, rep, repCount, ctx) {
542
568
  judgeCalls = o.metrics?.judge_calls ?? 0;
543
569
  }
544
570
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
545
- const subject = mergeTraces(traces)?.metrics;
571
+ const subject = execution.status === "invalid" ? undefined : mergeTraces(traces)?.metrics;
546
572
  return {
547
573
  verdict, reason, suspect, objective, judgment,
548
574
  metrics: {
@@ -632,7 +658,12 @@ export function formatScorecard(summary, lift, stability) {
632
658
  lines.push(` NOTE: green delivery is harness-version-dependent` +
633
659
  (results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
634
660
  ` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
635
- ` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
661
+ ` Use --mode force for explicit instruction delivery, or --canary for one instructed loading check; autonomous selection remains untested.`);
662
+ }
663
+ if (results.mode === "green" && results.delivery_canary === "pass") {
664
+ const observation = results.delivery_observations;
665
+ lines.push(` DELIVERY: instructed probe read ${observation?.successfulSkillRead ?? "unobserved"}; ` +
666
+ `autonomous skill selection in scenarios was not measured by the probe.`);
636
667
  }
637
668
  // Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
638
669
  // flipped between the last two runs is one draw, whatever its rep count said.
@@ -0,0 +1,15 @@
1
+ import type { Spec } from "./spec.js";
2
+ export interface SavedAnswerPlan {
3
+ providerCalls: 0;
4
+ resultsChanged: false;
5
+ scenarios: {
6
+ id: string;
7
+ action: "reuse" | "regrade" | "regate" | "rescore" | "rerun" | "inspect";
8
+ changed: string[];
9
+ note: string;
10
+ }[];
11
+ }
12
+ /** This is an input-identity plan, never an approval or a claim that a failed answer improved. */
13
+ export declare function planSavedAnswers(runDir: string, spec: Spec, skillDir: string, specDir: string): SavedAnswerPlan;
14
+ /** Copy a selected run, with limits and byte identities, before revising its judgments. */
15
+ export declare function forkSavedRun(source: string, destination: string): string;
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { readResults, findTranscriptFiles, transcriptPath } from "./results.js";
5
+ import { currentHashFor, isSupersededKey, sourceHashes } from "./sources.js";
6
+ /** This is an input-identity plan, never an approval or a claim that a failed answer improved. */
7
+ export function planSavedAnswers(runDir, spec, skillDir, specDir) {
8
+ const previous = readResults(runDir);
9
+ const context = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
10
+ const recorded = previous.source_hashes;
11
+ const current = sourceHashes(context);
12
+ const changed = Object.keys({ ...recorded, ...current }).filter(key => {
13
+ if (isSupersededKey(key, { ...recorded, ...current }))
14
+ return false;
15
+ return !recorded || !(key in recorded) || currentHashFor(key, context) !== recorded[key];
16
+ });
17
+ return { providerCalls: 0, resultsChanged: false, scenarios: spec.scenarios.map(s => {
18
+ const prior = previous.scenarios.find(p => p.id === s.id);
19
+ const relevant = changed.filter(key => {
20
+ if (/^(stimulus|rubric|policy|gates|scenario):/.test(key) && key !== "rubric:__persona")
21
+ return key.slice(key.indexOf(":") + 1) === s.id;
22
+ if (key.startsWith("fixture:"))
23
+ return key.slice(8) === (typeof s.workspace === "object" ? s.workspace.fixture : s.fixture);
24
+ if (key.startsWith("prompt:"))
25
+ return key.slice(7) === s.systemPromptFile;
26
+ if (!key.includes(":") && key !== "SKILL.md")
27
+ return [s.systemPromptFile, s.assert?.post_test, ...(s.extensions ?? [])].includes(key);
28
+ return true;
29
+ });
30
+ const base = { id: s.id, changed: relevant };
31
+ if (!prior)
32
+ return { ...base, action: "rerun", note: "No saved answer exists for this scenario." };
33
+ const files = findTranscriptFiles(runDir, s.id, previous.mode);
34
+ const reps = prior.reps ?? 1;
35
+ const complete = Number.isSafeInteger(reps) && reps > 0 && files.length === reps && files.every((file, rep) => {
36
+ if (file !== basename(transcriptPath(runDir, s.id, previous.mode, reps === 1 ? undefined : rep)))
37
+ return false;
38
+ try {
39
+ const stat = lstatSync(join(runDir, file));
40
+ return stat.isFile() && !stat.isSymbolicLink() && stat.size > 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ });
46
+ if (!complete)
47
+ return { ...base, action: "inspect", note: "Saved answers are incomplete; recover original evidence before planning paid work." };
48
+ if (!recorded || !recorded[`stimulus:${s.id}`])
49
+ return { ...base, action: "inspect", note: "Input identity is incomplete; do not assume a saved answer matches this task." };
50
+ if (relevant.some(key => key in recorded && currentHashFor(key, context) === undefined))
51
+ return { ...base, action: "inspect", note: "An input identity cannot be interpreted by this harness; inspect the original evidence before planning a rerun." };
52
+ if (prior.judge_verdict === "ERROR")
53
+ return { ...base, action: "inspect", note: "Resolve transport/capture errors first; recover saved answers before considering another subject run." };
54
+ const stimulusChanged = relevant.some(key => !/^(rubric|gates|policy):/.test(key));
55
+ if (stimulusChanged)
56
+ return { ...base, action: "rerun", note: "Subject inputs or skill changed. Old answers remain historical and cannot establish current behavior." };
57
+ if (relevant.some(k => k.startsWith("gates:")))
58
+ return { ...base, action: "regate", note: "Replay supported assertions on saved evidence; missing execution evidence remains unknown. Regrade too if the rubric changed." };
59
+ if (relevant.some(k => k.startsWith("rubric:")))
60
+ return { ...base, action: "regrade", note: "Judge the saved answers against corrected references/rubric; no subject call is needed." };
61
+ if (relevant.some(k => k.startsWith("policy:")))
62
+ return { ...base, action: "rescore", note: "Recompute policy using retained verdicts; no provider calls." };
63
+ return { ...base, action: "reuse", note: "Inputs match. Retain the existing verdict, including failures; this is not a new stability sample." };
64
+ }) };
65
+ }
66
+ /** Copy a selected run, with limits and byte identities, before revising its judgments. */
67
+ export function forkSavedRun(source, destination) {
68
+ const root = realpathSync(source), out = join(realpathSync(dirname(resolve(destination))), basename(destination));
69
+ const rel = relative(root, out);
70
+ if (!rel || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\")))
71
+ throw new Error("Saved-run output must be outside the original run");
72
+ const files = [];
73
+ let bytes = 0, entries = 0;
74
+ const sha = (data) => createHash("sha256").update(data).digest("hex");
75
+ const visit = (dir, prefix, depth) => {
76
+ if (depth > 16)
77
+ throw new Error("Saved-run copy exceeds directory depth limit");
78
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
79
+ if (++entries > 4096)
80
+ throw new Error("Saved-run copy exceeds 4096 entries");
81
+ const path = join(dir, entry.name), key = prefix + entry.name, stat = lstatSync(path);
82
+ if (stat.isSymbolicLink())
83
+ throw new Error("Saved-run copy refuses symbolic links");
84
+ if (stat.isDirectory())
85
+ visit(path, key + "/", depth + 1);
86
+ else if (stat.isFile()) {
87
+ bytes += stat.size;
88
+ if (stat.size > 64 * 1024 * 1024 || bytes > 256 * 1024 * 1024)
89
+ throw new Error("Saved-run copy exceeds evidence byte limit; select a smaller run");
90
+ files.push({ path: key, bytes: stat.size, sha256: sha(readFileSync(path)) });
91
+ }
92
+ else
93
+ throw new Error("Saved-run copy requires regular files");
94
+ }
95
+ };
96
+ visit(root, "", 0);
97
+ if (!files.some(f => f.path === "results.yaml"))
98
+ throw new Error("Saved run lacks results.yaml");
99
+ mkdirSync(out, { mode: 0o700 }); // Exclusive: never overwrite another result directory.
100
+ for (const file of files) {
101
+ const data = readFileSync(join(root, file.path));
102
+ if (sha(data) !== file.sha256)
103
+ throw new Error("Saved evidence changed during copy; output is incomplete");
104
+ const target = join(out, file.path);
105
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
106
+ writeFileSync(target, data, { flag: "wx", mode: 0o600 });
107
+ }
108
+ for (const file of files)
109
+ if (sha(readFileSync(join(root, file.path))) !== file.sha256 || sha(readFileSync(join(out, file.path))) !== file.sha256)
110
+ throw new Error("Saved evidence verification failed; output is incomplete");
111
+ writeFileSync(join(out, `reuse-source-${Date.now()}.json`), JSON.stringify({ schema: 1, purpose: "saved-answer-regrade", source: root, files, subjectCalls: 0 }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
112
+ return out;
113
+ }
114
+ //# sourceMappingURL=saved-run.js.map
@@ -2,10 +2,8 @@
2
2
  * Run `tasks` with at most `concurrency` thunks in flight at once, returning
3
3
  * their results in input order (not completion order). `concurrency <= 1` runs
4
4
  * them strictly sequentially — identical to a plain for-await loop. A thunk that
5
- * throws rejects the returned promise (fail-fast) — once any task rejects,
6
- * `runPool` rejects immediately. Tasks already claimed by other workers still
7
- * run to completion (JS has no cancellation), and sibling workers may pull
8
- * further tasks before the rejection unwinds; the guarantee is that runPool
9
- * rejects, not that dispatch halts.
5
+ * throws stops new dispatch; already claimed tasks settle before rejection.
6
+ * A surrounding process scope supplies cancellation to active subprocesses.
7
+ * Waiting for settlement prevents reporting a stopped run while its workers remain active.
10
8
  */
11
9
  export declare function runPool<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]>;
package/dist/scheduler.js CHANGED
@@ -2,26 +2,38 @@
2
2
  * Run `tasks` with at most `concurrency` thunks in flight at once, returning
3
3
  * their results in input order (not completion order). `concurrency <= 1` runs
4
4
  * them strictly sequentially — identical to a plain for-await loop. A thunk that
5
- * throws rejects the returned promise (fail-fast) — once any task rejects,
6
- * `runPool` rejects immediately. Tasks already claimed by other workers still
7
- * run to completion (JS has no cancellation), and sibling workers may pull
8
- * further tasks before the rejection unwinds; the guarantee is that runPool
9
- * rejects, not that dispatch halts.
5
+ * throws stops new dispatch; already claimed tasks settle before rejection.
6
+ * A surrounding process scope supplies cancellation to active subprocesses.
7
+ * Waiting for settlement prevents reporting a stopped run while its workers remain active.
10
8
  */
11
9
  export async function runPool(tasks, concurrency) {
12
10
  const limit = Math.max(1, Math.floor(concurrency));
13
11
  const results = new Array(tasks.length);
14
12
  let next = 0;
13
+ let failure;
14
+ let failed = false;
15
15
  async function worker() {
16
16
  while (true) {
17
+ if (failed)
18
+ return;
17
19
  const i = next++;
18
20
  if (i >= tasks.length)
19
21
  return;
20
- results[i] = await tasks[i]();
22
+ try {
23
+ results[i] = await tasks[i]();
24
+ }
25
+ catch (error) {
26
+ if (!failed)
27
+ failure = error;
28
+ failed = true;
29
+ return;
30
+ }
21
31
  }
22
32
  }
23
33
  const workerCount = Math.min(limit, tasks.length);
24
34
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
35
+ if (failed)
36
+ throw failure;
25
37
  return results;
26
38
  }
27
39
  //# sourceMappingURL=scheduler.js.map
package/dist/seeded.js CHANGED
@@ -234,6 +234,7 @@ export async function runSeeded(scenario, opts) {
234
234
  model: opts.model,
235
235
  mode: opts.mode,
236
236
  turns: scenario.turns,
237
+ tools: scenario.tools,
237
238
  cwd: repo,
238
239
  // Resolved against the spec dir, exactly like fixtures and post-tests —
239
240
  // the arm's extensions (already absolute) join alongside, same as the
package/dist/sources.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Scenario } from "./spec.js";
2
+ import { type JudgeReference } from "./judge-references.js";
2
3
  export { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
3
4
  /**
4
5
  * What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
@@ -103,7 +104,7 @@ export declare function fileSha256(path: string): string | null;
103
104
  */
104
105
  export declare function dirSha256(dir: string): string | null;
105
106
  export declare function stimulusDigest(s: Scenario): string;
106
- export declare function rubricDigest(s: Scenario): string;
107
+ export declare function rubricDigest(s: Scenario, specDir?: string, references?: JudgeReference[]): string;
107
108
  export declare function policyDigest(s: Scenario): string;
108
109
  /** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
109
110
  export declare function gatesDigest(s: Scenario): string | null;
@@ -143,6 +144,8 @@ export interface SourceContext {
143
144
  * caller instead of leaving one quietly un-checked.
144
145
  */
145
146
  judgePersona: string;
147
+ /** Snapshot resolved before execution, shared by prompt construction and recorded rubric hashes. */
148
+ judgeReferences?: Record<string, JudgeReference[]>;
146
149
  }
147
150
  /**
148
151
  * Hash every source this run measures: SKILL.md, each distinct