@skill-harness/core 0.30.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.
@@ -0,0 +1,72 @@
1
+ import { mkdirSync, writeFileSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { getProcessSignal, withProcessScope } from "./util/process.js";
5
+ /** Per-operation metadata only, with bounded retention and low-frequency atomic snapshots. */
6
+ export async function withResourceMonitoring(directory, work, options = {}) {
7
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
8
+ const id = randomUUID();
9
+ const path = join(directory, `resources-${id}.json`), pending = path + ".tmp";
10
+ const controller = new AbortController();
11
+ const inherited = options.signal ?? getProcessSignal();
12
+ const abort = () => controller.abort(inherited?.reason);
13
+ if (inherited?.aborted)
14
+ abort();
15
+ inherited?.addEventListener("abort", abort, { once: true });
16
+ const records = [];
17
+ const active = new Map();
18
+ let total = 0, dropped = 0, droppedActive = 0, state = "running", flushError;
19
+ const started = new Date().toISOString();
20
+ const flush = () => {
21
+ try {
22
+ writeFileSync(pending, JSON.stringify({ schema: 1, scope: "harness-owned-processes", started, updated: new Date().toISOString(), state,
23
+ hostHealth: "unmeasured", sampling: "one-second-owned-tree", totalProcesses: total, droppedObservations: dropped, droppedActiveObservations: droppedActive, active: [...active.values()], observations: records }, null, 2) + "\n", { mode: 0o600 });
24
+ renameSync(pending, path);
25
+ }
26
+ catch (error) {
27
+ flushError = error;
28
+ controller.abort(error);
29
+ }
30
+ };
31
+ flush();
32
+ if (flushError) {
33
+ inherited?.removeEventListener("abort", abort);
34
+ throw flushError;
35
+ }
36
+ const timer = setInterval(flush, 5000);
37
+ timer.unref();
38
+ try {
39
+ return await withProcessScope({ ...options, signal: controller.signal, onSample: record => {
40
+ if (record.pid !== null && (active.has(record.pid) || active.size < 256))
41
+ active.set(record.pid, record);
42
+ else
43
+ droppedActive++;
44
+ options.onSample?.(record);
45
+ }, onObservation: record => {
46
+ total++;
47
+ if (record.pid !== null)
48
+ active.delete(record.pid);
49
+ if (records.length < 256)
50
+ records.push(record);
51
+ else
52
+ dropped++;
53
+ if (record.reason === "resource-limit" || record.reason === "output-limit")
54
+ controller.abort(new Error(record.reason));
55
+ options.onObservation?.(record);
56
+ } }, work);
57
+ }
58
+ catch (error) {
59
+ state = "interrupted";
60
+ throw error;
61
+ }
62
+ finally {
63
+ clearInterval(timer);
64
+ inherited?.removeEventListener("abort", abort);
65
+ if (state === "running")
66
+ state = "settled";
67
+ flush();
68
+ if (flushError)
69
+ throw new Error(`Resource summary could not be retained: ${String(flushError)}`);
70
+ }
71
+ }
72
+ //# sourceMappingURL=resource-monitor.js.map
package/dist/results.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type ScenarioVerdict } from "./score.js";
3
3
  import type { Verdict } from "./score.js";
4
4
  import type { ShipBar, Scenario } from "./spec.js";
5
5
  import type { CostSource } from "./capture-trace-types.js";
6
+ import type { CanaryObservations } from "./canary.js";
6
7
  export interface SubjectUsage {
7
8
  repetition: number;
8
9
  inputTokens: number | null;
@@ -181,10 +182,10 @@ export interface ResultsFile {
181
182
  */
182
183
  harness_cli_version?: string;
183
184
  /**
184
- * `pass` when this run proved, before spending the wave, that the skill body was
185
- * reachable in the model's context (see canary.ts). `skipped` when the probe was
186
- * asked for but could not be performed — SKILL.md has no `## ` heading to quote
187
- * back, so no reply could prove anything. Absent means the probe was not asked
185
+ * Compatibility status for the optional instructed loading/quote probe.
186
+ * See delivery_observations for what was actually observed; a quote by itself
187
+ * does not establish loading, and the probe never tests autonomous selection.
188
+ * `skipped` means the skill has no `## ` heading. Absent means the probe was not asked
188
189
  * for — never that it failed, because a failed canary aborts the run and no
189
190
  * results.yaml is written.
190
191
  *
@@ -199,6 +200,10 @@ export interface ResultsFile {
199
200
  * gitignored, and this claim has to survive a commit.
200
201
  */
201
202
  delivery_canary?: "pass" | "skipped";
203
+ /** Optional for historical records; never inferred from a legacy pass alone. */
204
+ delivery_observations?: CanaryObservations & {
205
+ probeCalls: 0 | 1;
206
+ };
202
207
  skill: string;
203
208
  harness: string;
204
209
  model: string;
package/dist/results.js CHANGED
@@ -224,6 +224,7 @@ export function finalizeResults(draft, ctx) {
224
224
  // not report a version must not look like one that reported "nothing".
225
225
  ...(draft.harness_cli_version ? { harness_cli_version: draft.harness_cli_version } : {}),
226
226
  ...(draft.delivery_canary ? { delivery_canary: draft.delivery_canary } : {}),
227
+ ...(draft.delivery_observations ? { delivery_observations: draft.delivery_observations } : {}),
227
228
  skill: draft.skill,
228
229
  harness: draft.harness,
229
230
  model: draft.model,
@@ -498,6 +499,9 @@ export function applyOverride(results, scenarioId, override, note) {
498
499
  const GITIGNORE_BODY = `# skill-harness: commit verdicts (results.yaml), ignore generated artifacts.
499
500
  *.txt
500
501
  *.jsonl
502
+ resources-*.json
503
+ resources-*.json.tmp
504
+ reuse-source-*.json
501
505
  report.html
502
506
  !results.yaml
503
507
  `;
package/dist/run.d.ts CHANGED
@@ -31,8 +31,8 @@ export interface RunOptions {
31
31
  */
32
32
  only?: string[];
33
33
  /**
34
- * Green mode only: spend ONE probe up front proving the skill reaches the model,
35
- * and abort the run if it doesn't (see canary.ts). Off by default — it costs a
34
+ * Green mode only: spend ONE instructed loading/read/quote probe up front,
35
+ * and stop the wave if inconclusive (see canary.ts). Off by default — it costs a
36
36
  * rep, and the deterministic half of this failure class (a skill dir that isn't
37
37
  * there) is already refused by the adapter for free.
38
38
  */
package/dist/run.js CHANGED
@@ -19,6 +19,8 @@ import { runDeliveryCanary, canaryFailure } from "./canary.js";
19
19
  import { boundaryCells, stabilityNote } from "./stability.js";
20
20
  import { aggregateMetrics } from "./metrics.js";
21
21
  import { providerFailureFromTranscript, executionFailureFromTranscript, withExecutionFailure } from "./provider-failure.js";
22
+ import { withResourceMonitoring } from "./resource-monitor.js";
23
+ import { throwIfProcessAborted, isProcessControlError } from "./util/process.js";
22
24
  /**
23
25
  * The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
24
26
  * `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
@@ -54,7 +56,10 @@ export async function runSkillModel(opts) {
54
56
  // --only: validate against the spec BEFORE spending anything — a typo'd id must not
55
57
  // silently run zero scenarios and report success.
56
58
  let scenarios = spec.scenarios;
57
- 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
+ }
58
63
  if (partial) {
59
64
  const known = new Set(spec.scenarios.map((s) => s.id));
60
65
  const unknown = opts.only.filter((id) => !known.has(id));
@@ -78,118 +83,124 @@ export async function runSkillModel(opts) {
78
83
  const runDir = runDirFor(dirname(opts.testsDir ?? join(skillDir, "tests")), adapter.name, model, timestamp, arm.name);
79
84
  mkdirSync(runDir, { recursive: true });
80
85
  ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
81
- // Which harness CLI delivered the skill, asked once per run and recorded with the
82
- // numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
83
- // measures, and the incident was invisible in the artifacts because nothing wrote
84
- // this down. Never fatal: `null` means the adapter couldn't say.
85
- const harnessCliVersion = (await adapter.version?.()) ?? null;
86
- appendJournal(runDir, {
87
- event: "run-started", ts: now(),
88
- skill: spec.skill, harness: adapter.name, model: opts.modelToken,
89
- harness_cli_version: harnessCliVersion,
90
- judge: { provider: judge.provider, model: judge.model },
91
- mode, label: opts.label ?? null,
92
- });
93
- // The canary spends one probe before the wave, so a run that isn't measuring the
94
- // skill dies for the price of a rep instead of producing a plausible scorecard.
95
- // Green only: red delivers nothing by design, and force delivers through the
96
- // system prompt, which needs no probe.
97
- let canaryStatus = null;
98
- if (opts.canary && mode !== "green") {
99
- // Ignoring a flag silently is a small version of the bug this whole feature is
100
- // about. Say it, and say why it isn't needed.
101
- log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
102
- }
103
- if (opts.canary && mode === "green") {
104
- const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
105
- let canary;
106
- try {
107
- canary = await runDeliveryCanary({
108
- adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
109
- });
110
- }
111
- finally {
112
- probeCwd.cleanup();
113
- }
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;
114
93
  appendJournal(runDir, {
115
- event: "delivery-canary", ts: now(),
116
- 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,
117
99
  });
118
- if (canary.status === "fail")
119
- throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
120
- if (canary.status === "skipped") {
121
- // Recorded, not just logged: `journal.jsonl` is gitignored, and the claim
122
- // "this run's delivery was verified" has to survive a commit — including
123
- // when it is the claim that it wasn't.
124
- canaryStatus = "skipped";
125
- 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"}`);
126
110
  }
127
- else {
128
- canaryStatus = "pass";
129
- 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
+ }
130
140
  }
131
- }
132
- // scenario × rep tasks; runPool preserves input order so we can slice per scenario.
133
- const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
134
- const owners = [];
135
- const tasks = [];
136
- const armDefinitions = { count: 0 };
137
- scenarios.forEach((scenario, si) => {
138
- for (let k = 0; k < repCounts[si]; k++) {
139
- const rep = k;
140
- const total = repCounts[si];
141
- owners.push(si);
142
- tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions, judgeReferences }));
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 });
143
201
  }
202
+ return { runDir, results };
144
203
  });
145
- const flat = await runPool(tasks, opts.concurrency ?? 1);
146
- const grouped = scenarios.map(() => []);
147
- flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
148
- const scenarioResults = scenarios.map((scenario, si) => {
149
- const threshold = scenario.critical
150
- ? effectiveThreshold(undefined, scenario)
151
- : scenario.passThreshold ?? opts.passThreshold ?? 0.5;
152
- return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
153
- });
154
- const ctx = scoreContextFor({ mode, partial }, spec);
155
- const results = writeResults(runDir, {
156
- skill: spec.skill,
157
- harness: adapter.name,
158
- harness_cli_version: harnessCliVersion ?? undefined,
159
- delivery_canary: canaryStatus ?? undefined,
160
- model: opts.modelToken,
161
- judge: { provider: judge.provider, model: judge.model },
162
- timestamp,
163
- label: opts.label ?? null,
164
- mode,
165
- ...(partial ? { partial: true } : {}),
166
- // Only the scenarios this run actually measured: a --only run must not claim
167
- // coverage of scenarios it skipped.
168
- source_hashes: hashes,
169
- ...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
170
- scenarios: scenarioResults,
171
- ...(arm.name === NONE_ARM.name ? {} : {
172
- arm: {
173
- name: arm.name,
174
- extensions: arm.extensions,
175
- definitions: armDefinitions.count,
176
- ledger_events: countLedgerEvents(runDir),
177
- // The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
178
- // being measured (grant, max depth), so leaving it out made two runs at
179
- // different settings byte-identical here and inside the same `+<arm>`
180
- // tag — `stability` then reads the verdict difference between two
181
- // conditions as one lineage flipping. The substituted form would be the
182
- // opposite error: it embeds this run's temp path, so re-running the SAME
183
- // condition would record two different-looking arms.
184
- env: arm.env,
185
- },
186
- }),
187
- }, ctx);
188
- if (ctx) {
189
- const g = results.effective_grade;
190
- appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
191
- }
192
- return { runDir, results };
193
204
  }
194
205
  /**
195
206
  * True when any assistant turn in a transcript is blank — the shape a harness timeout
@@ -288,6 +299,7 @@ async function runRep(scenario, rep, repCount, ctx) {
288
299
  // it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
289
300
  // An adapter that THREW is the same class of event and takes the same retry.
290
301
  for (let attempt = 0; attempt < 2; attempt++) {
302
+ throwIfProcessAborted();
291
303
  if (attempt > 0) {
292
304
  const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
293
305
  appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
@@ -368,6 +380,9 @@ async function runRep(scenario, rep, repCount, ctx) {
368
380
  }
369
381
  }
370
382
  catch (e) {
383
+ if (isProcessControlError(e))
384
+ throw e;
385
+ throwIfProcessAborted();
371
386
  // An adapter that THREW is one rep's infrastructure failure, not the
372
387
  // wave's. `runPool` is fail-fast and `runRep` had only a `finally`, so
373
388
  // a single pi crash or `PI_TIMEOUT_MS` timeout unwound the whole run:
@@ -643,7 +658,12 @@ export function formatScorecard(summary, lift, stability) {
643
658
  lines.push(` NOTE: green delivery is harness-version-dependent` +
644
659
  (results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
645
660
  ` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
646
- ` 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.`);
647
667
  }
648
668
  // Boundary cells last, because they qualify the verdicts above: a ✓ on a cell that
649
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