@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.
@@ -1,7 +1,7 @@
1
1
  import type { Spec } from "./spec.js";
2
2
  export interface AuthoringFinding {
3
3
  scenario: string;
4
- code: "INPUT_DISCOVERY" | "READ_COUNT" | "PATH_PORTABILITY" | "COMMAND_SPELLING" | "TEST_SYNTAX" | "CITATION_FORMAT" | "UNAVAILABLE_FORBIDDEN_TOOL" | "UNAVAILABLE_REQUIRED_TOOL";
4
+ code: "INPUT_DISCOVERY" | "READ_COUNT" | "PATH_PORTABILITY" | "COMMAND_SPELLING" | "TEST_SYNTAX" | "CITATION_FORMAT" | "UNAVAILABLE_FORBIDDEN_TOOL" | "UNAVAILABLE_REQUIRED_TOOL" | "JUDGE_REFERENCE_GAP" | "MISSING_COVERAGE_MAP";
5
5
  severity?: "error";
6
6
  message: string;
7
7
  }
@@ -9,6 +9,14 @@ export interface AuthoringFinding {
9
9
  export declare function inspectEvalAuthoring(spec: Spec, profile?: {
10
10
  tools?: readonly string[];
11
11
  fixtureFiles?: (scenario: Spec["scenarios"][number]) => readonly string[];
12
+ /** Explicitly selected fixture/reference text only. No host search or automatic judge disclosure. */
13
+ fixtureText?: (scenario: Spec["scenarios"][number], file: string) => string | null;
14
+ referenceText?: (file: string) => string | null;
12
15
  }): AuthoringFinding[];
13
16
  /** Names only, never file contents. Do not follow symlinks or inspect private host directories. */
14
17
  export declare function fixtureInventory(root: string): string[];
18
+ /** Optional text inventory for offline lint. Bounded; never expands the judge's input. */
19
+ export declare function authoringEvidenceProfile(specDir: string): {
20
+ fixtureText: (s: Spec["scenarios"][number], file: string) => string | null;
21
+ referenceText: (file: string) => string | null;
22
+ };
@@ -1,5 +1,5 @@
1
- import { readdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, relative, isAbsolute, resolve } from "node:path";
3
3
  import { MARKERS } from "./workspace.js";
4
4
  /** Review hints only. A valid exact-format contract may intentionally trigger one. */
5
5
  export function inspectEvalAuthoring(spec, profile = {}) {
@@ -7,6 +7,21 @@ export function inspectEvalAuthoring(spec, profile = {}) {
7
7
  for (const s of spec.scenarios) {
8
8
  const tools = profile.tools ?? s.tools;
9
9
  const add = (code, message) => findings.push({ scenario: s.id, code, message });
10
+ if (!s.covers?.length)
11
+ add("MISSING_COVERAGE_MAP", "No instruction-section coverage is declared. Map the behavior this scenario can detect; a map is not proof of coverage.");
12
+ if (s.judgeEvidence === "response" && profile.fixtureText && profile.referenceText) {
13
+ const references = (s.judgeReferences ?? []).map(profile.referenceText).filter((text) => text !== null);
14
+ const normalize = (text) => text.replace(/\r\n/g, "\n").trim();
15
+ const visible = [...references, s.judgeContext ?? ""].map(normalize);
16
+ for (const file of profile.fixtureFiles?.(s) ?? []) {
17
+ if (!/\.(?:md|txt|json|ya?ml|csv)$/i.test(file))
18
+ continue;
19
+ const source = profile.fixtureText(s, file);
20
+ if (source !== null && normalize(source) && !visible.some(ref => ref.includes(normalize(source)))) {
21
+ add("JUDGE_REFERENCE_GAP", `Fixture ${file} is not present verbatim in judge references/context. Verify whether its claims are needed to judge valid answers, including brand, goals and conflicting facts. Deliberately private sources may remain excluded; document the reason. This is an inventory warning, not a semantic verdict.`);
22
+ }
23
+ }
24
+ }
10
25
  for (const call of s.traceAssert?.require_calls ?? []) {
11
26
  const pattern = call.args?.path?.matches;
12
27
  if (tools && !tools.includes(call.tool)) {
@@ -41,13 +56,18 @@ export function inspectEvalAuthoring(spec, profile = {}) {
41
56
  /** Names only, never file contents. Do not follow symlinks or inspect private host directories. */
42
57
  export function fixtureInventory(root) {
43
58
  const files = [];
44
- const visit = (directory, prefix) => {
59
+ let entries = 0;
60
+ const visit = (directory, prefix, depth = 0) => {
61
+ if (depth > 32)
62
+ throw new Error("Fixture inventory exceeds 32 directory levels; select a smaller fixture");
45
63
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
64
+ if (++entries > 4096)
65
+ throw new Error("Fixture inventory exceeds 4096 entries; select a smaller fixture");
46
66
  if (entry.name === ".git" || entry.isSymbolicLink())
47
67
  continue;
48
68
  const name = prefix + entry.name;
49
69
  if (entry.isDirectory())
50
- visit(join(directory, entry.name), name + "/");
70
+ visit(join(directory, entry.name), name + "/", depth + 1);
51
71
  else if (entry.isFile())
52
72
  files.push(MARKERS.some(marker => name.startsWith(marker + "/")) ? name.slice(name.indexOf("/") + 1) : name);
53
73
  }
@@ -55,4 +75,37 @@ export function fixtureInventory(root) {
55
75
  visit(root, "");
56
76
  return [...new Set(files)].sort();
57
77
  }
78
+ /** Optional text inventory for offline lint. Bounded; never expands the judge's input. */
79
+ export function authoringEvidenceProfile(specDir) {
80
+ let remaining = 2 * 1024 * 1024;
81
+ const cache = new Map();
82
+ const text = (file) => {
83
+ const path = resolve(specDir, file);
84
+ if (cache.has(path))
85
+ return cache.get(path);
86
+ let value = null;
87
+ try {
88
+ const rel = relative(realpathSync(specDir), realpathSync(path));
89
+ const stat = statSync(path), size = stat.size;
90
+ if (!stat.isFile() || isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || size > 65536 || size > remaining)
91
+ return null;
92
+ remaining -= size;
93
+ value = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
94
+ }
95
+ catch { /* Unreadable/binary source is not invented as empty evidence. */ }
96
+ cache.set(path, value);
97
+ return value;
98
+ };
99
+ const fixture = (s) => typeof s.workspace === "object" ? s.workspace.fixture : s.fixture;
100
+ return {
101
+ fixtureText: (s, file) => {
102
+ const root = fixture(s);
103
+ if (!root)
104
+ return null;
105
+ // Overlay order is the same as workspace creation: later pending content wins.
106
+ return text(join(root, "_uncommitted", file)) ?? text(join(root, "_staged", file)) ?? text(join(root, file));
107
+ },
108
+ referenceText: text,
109
+ };
110
+ }
58
111
  //# sourceMappingURL=authoring-preflight.js.map
package/dist/authoring.js CHANGED
@@ -300,7 +300,15 @@ function draftIssues(state, draft) {
300
300
  issues.push(`${scenario.id} support file is missing: ${path}`);
301
301
  }
302
302
  }
303
- for (const finding of inspectEvalAuthoring(spec, { fixtureFiles: scenario => {
303
+ for (const finding of inspectEvalAuthoring(spec, {
304
+ referenceText: path => lookup("evals", path),
305
+ fixtureText: (scenario, path) => {
306
+ const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
307
+ if (!fixture)
308
+ return null;
309
+ const prefix = fixture.replace(/\/$/, "");
310
+ return lookup("evals", `${prefix}/_uncommitted/${path}`) ?? lookup("evals", `${prefix}/_staged/${path}`) ?? lookup("evals", `${prefix}/${path}`);
311
+ }, fixtureFiles: scenario => {
304
312
  const fixture = typeof scenario.workspace === "object" ? scenario.workspace.fixture : scenario.fixture;
305
313
  if (!fixture)
306
314
  return [];
@@ -310,7 +318,8 @@ function draftIssues(state, draft) {
310
318
  const actual = resolve(locations(state).evals, fixture);
311
319
  noLinks(actual);
312
320
  return [...new Set([...staged, ...(existsSync(actual) && lstatSync(actual).isDirectory() ? fixtureInventory(actual) : [])])];
313
- } })) {
321
+ }
322
+ })) {
314
323
  (finding.severity === "error" ? issues : warnings).push(`${finding.scenario}: ${finding.message}`);
315
324
  }
316
325
  if (state.answers.placement === "separate-repo" && existsSync(join(state.skillDir, "tests", "specification.yaml"))) {
@@ -37,7 +37,11 @@ export interface LoadedCalibration {
37
37
  spec: Spec;
38
38
  controls: Array<BoundControl & {
39
39
  prompt: string;
40
+ executionEvidence: "verified" | "unknown";
40
41
  }>;
42
+ /** Calibration judges supplied answers; even a verified trace is not a gate evaluation. */
43
+ calibrationScope: "answer-judge";
44
+ executionGates: "not-evaluated";
41
45
  ready: boolean;
42
46
  issues: string[];
43
47
  }
@@ -49,6 +53,43 @@ export declare function prepareCalibration(options: {
49
53
  }): LoadedCalibration;
50
54
  /** Offline validation only. No Pi, provider access, or result rewrite. */
51
55
  export declare function loadCalibration(manifestPath: string): LoadedCalibration;
56
+ export interface CalibrationReuseSource {
57
+ directory: string;
58
+ manifestSha256: string;
59
+ artifacts: {
60
+ path: string;
61
+ sha256: string;
62
+ }[];
63
+ }
64
+ export interface CalibrationSelection {
65
+ only?: string[];
66
+ reuse?: string[];
67
+ }
68
+ export interface CalibrationPlan {
69
+ schema: 1;
70
+ purpose: "judge-calibration-plan";
71
+ manifestSha256: string;
72
+ judge: ModelRef;
73
+ calibrationScope: "answer-judge";
74
+ executionGates: "not-evaluated";
75
+ availableCases: number;
76
+ cases: number;
77
+ partial: boolean;
78
+ ready: boolean;
79
+ issues: string[];
80
+ notes: string[];
81
+ judgeCallsPlanned: number;
82
+ reused: number;
83
+ controls: {
84
+ id: string;
85
+ action: "judge" | "reuse";
86
+ source?: CalibrationReuseSource;
87
+ }[];
88
+ }
89
+ /** Preview eligible historical judgments. Reuse is not a fresh stability measurement. */
90
+ export declare function planCalibration(loaded: LoadedCalibration, options: CalibrationSelection & {
91
+ judge: ModelRef;
92
+ }): CalibrationPlan;
52
93
  export interface CalibrationSummary {
53
94
  schema: 1;
54
95
  purpose: "judge-calibration";
@@ -60,6 +101,20 @@ export interface CalibrationSummary {
60
101
  matched: number;
61
102
  judgeCalls: number;
62
103
  status: "matched" | "mismatch" | "error";
104
+ calibrationScope: "answer-judge";
105
+ executionGates: "not-evaluated";
106
+ availableCases: number;
107
+ partial: boolean;
108
+ disagreements: {
109
+ id: string;
110
+ expected: "PASS" | "FAIL";
111
+ observed: "PASS" | "FAIL";
112
+ }[];
113
+ reused: {
114
+ id: string;
115
+ source: CalibrationReuseSource;
116
+ }[];
117
+ /** Legacy direction aliases for label disagreements, not adjudicated judge errors. */
63
118
  falsePasses: string[];
64
119
  falseFailures: string[];
65
120
  errors: string[];
@@ -72,6 +127,8 @@ export declare function runCalibration(loaded: LoadedCalibration, options: {
72
127
  judge: ModelRef;
73
128
  output: string;
74
129
  allowMeteredJudge?: boolean;
130
+ only?: string[];
131
+ reuse?: string[];
75
132
  onProgress?: (progress: {
76
133
  id: string;
77
134
  index: number;
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { loadSpec } from "./spec.js";
5
- import { prepareJudgePrompt, judgeInWorkspace } from "./grade.js";
5
+ import { prepareJudgePrompt, judgeInWorkspace, parseStructuredJudgeReply } from "./grade.js";
6
6
  import { loadExecutionFacts, transcriptResponses, unavailableExecution } from "./judge-evidence.js";
7
7
  import { assertJudgeAllowed } from "./judge-policy.js";
8
8
  import { rubricDigest } from "./sources.js";
9
9
  import { resolveJudgeReferences } from "./judge-references.js";
10
+ import { withResourceMonitoring } from "./resource-monitor.js";
11
+ import { isProcessControlError, throwIfProcessAborted } from "./util/process.js";
10
12
  const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
11
13
  const HASH = /^[a-f0-9]{64}$/;
12
14
  const ID = /^[A-Za-z][A-Za-z0-9_-]{0,79}$/;
@@ -88,11 +90,12 @@ function validateManifest(manifest, manifestPath, manifestSha256) {
88
90
  if (execution.status !== "verified" || execution.coverage !== "complete")
89
91
  throw new Error(`${control.id}: execution evidence ERROR: ${execution.reason}`);
90
92
  }
91
- else if (scenario.traceAssert) {
92
- issues.push(`${control.id}: execution assertions need bound trace evidence`);
93
- }
94
- const prompt = prepareJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript: control.transcript, execution, references }, specDir);
95
- return { ...control, prompt };
93
+ const scope = control.kind === "saved"
94
+ ? "This is a selected saved answer. Only the separately verified execution facts establish observed execution."
95
+ : "This is an authored answer control. Any supplied code, staged diff, test output or gate trailer is synthetic control material, not evidence that the subject executed it.";
96
+ const prompt = prepareJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript: control.transcript, execution, references }, specDir) +
97
+ `\nCALIBRATION SCOPE (trusted harness metadata):\n${scope} Assess the supplied response/code against the semantic checklist and references. Tool use, execution and unchanged-file claims remain unverified unless supported by the separate execution facts. This run does not evaluate objective execution gates, even when a trace is verified. Keep the required JSON response format.\n`;
98
+ return { ...control, prompt, executionEvidence: execution.status === "verified" ? "verified" : "unknown" };
96
99
  });
97
100
  for (const control of controls.filter(c => c.kind === "equivalent")) {
98
101
  const original = controls.find(c => c.id === control.equivalentTo);
@@ -105,7 +108,8 @@ function validateManifest(manifest, manifestPath, manifestSha256) {
105
108
  issues.push("Add a deliberately incorrect negative control");
106
109
  if (!controls.some(c => c.expected === "PASS" && c.kind === "equivalent"))
107
110
  issues.push("Add an equivalent correct answer to detect wording overfit");
108
- return { manifestPath: resolve(manifestPath), manifestSha256, specDir, spec, controls, ready: !issues.length, issues };
111
+ return { manifestPath: resolve(manifestPath), manifestSha256, specDir, spec, controls,
112
+ calibrationScope: "answer-judge", executionGates: "not-evaluated", ready: !issues.length, issues };
109
113
  }
110
114
  /** Deterministic authoring helper. Hashes are produced by code, never requested from a model. */
111
115
  export function prepareCalibration(options) {
@@ -144,69 +148,205 @@ export function loadCalibration(manifestPath) {
144
148
  throw new Error("Calibration manifest exceeds 4 MiB");
145
149
  return validateManifest(JSON.parse(bytes.toString("utf8")), manifestPath, hash(bytes));
146
150
  }
147
- /** Explicit Pi-backed judging; semantic mismatches continue, infrastructure errors stop the remaining calls. */
148
- export async function runCalibration(loaded, options) {
151
+ /** Explicitly selected prior evidence only: no directory discovery and no model calls. */
152
+ function calibrationPlan(loaded, options) {
149
153
  const current = loadCalibration(loaded.manifestPath);
150
154
  if (current.manifestSha256 !== loaded.manifestSha256)
151
155
  throw new Error("Calibration changed after validation");
152
- if (!current.ready)
153
- throw new Error(`Calibration is not ready: ${current.issues.join("; ")}`);
156
+ if (options.only && (!options.only.length || new Set(options.only).size !== options.only.length ||
157
+ options.only.some(id => !current.controls.some(control => control.id === id))))
158
+ throw new Error("Select distinct existing calibration control IDs");
159
+ if ((options.reuse?.length ?? 0) > 20)
160
+ throw new Error("Select at most 20 prior calibration directories");
161
+ const selected = current.controls.filter(control => !options.only || options.only.includes(control.id));
162
+ const plan = {
163
+ schema: 1, purpose: "judge-calibration-plan", manifestSha256: current.manifestSha256, judge: options.judge,
164
+ calibrationScope: "answer-judge", executionGates: "not-evaluated", availableCases: current.controls.length,
165
+ cases: selected.length, partial: selected.length !== current.controls.length, ready: current.ready,
166
+ issues: [...current.issues], notes: [], judgeCallsPlanned: selected.length, reused: 0,
167
+ controls: selected.map(control => ({ id: control.id, action: "judge" })),
168
+ };
169
+ const retained = new Map();
170
+ // ModelRef currently has no sampling/settings fields. Unknown future settings must not silently reuse.
171
+ const sameJudge = (value) => value?.provider === options.judge.provider && value?.model === options.judge.model &&
172
+ Object.keys(value).sort().join(",") === "model,provider" && Object.keys(options.judge).sort().join(",") === "model,provider";
173
+ for (const directory of [...new Set(options.reuse ?? [])].map(path => resolve(path))) {
174
+ const evidence = [];
175
+ const files = new Map();
176
+ const read = (path, limit = 2_097_152) => {
177
+ const bytes = readFileSync(selectedFile(directory, path, limit));
178
+ evidence.push({ path, sha256: hash(bytes) });
179
+ files.set(path, bytes);
180
+ return JSON.parse(bytes.toString("utf8"));
181
+ };
182
+ const inputs = read("inputs.json", 33_554_432), summary = read("summary.json");
183
+ if (summary.schema !== 1 || summary.purpose !== "judge-calibration" || !HASH.test(summary.manifestSha256) ||
184
+ summary.manifestSha256 !== inputs.manifestSha256 || !Array.isArray(inputs.controls) || inputs.controls.length > 100 ||
185
+ !["matched", "mismatch", "error"].includes(summary.status) ||
186
+ !Array.isArray(summary.errors) || !Array.isArray(summary.unrun))
187
+ throw new Error(`Invalid prior calibration evidence: ${directory}`);
188
+ if (!sameJudge(inputs.judge) || !sameJudge(summary.judge)) {
189
+ plan.notes.push(`${directory}: different judge; no results reused`);
190
+ continue;
191
+ }
192
+ const common = evidence.slice();
193
+ for (const control of selected) {
194
+ const prior = inputs.controls.find((candidate) => candidate.id === control.id);
195
+ if (!prior || summary.errors.includes(control.id) || summary.unrun.includes(control.id))
196
+ continue;
197
+ if (prior.scenarioId !== control.scenarioId || prior.expected !== control.expected || prior.prompt !== control.prompt ||
198
+ prior.transcript !== control.transcript || prior.transcriptSha256 !== control.transcriptSha256 ||
199
+ prior.rubricSha256 !== control.rubricSha256 || prior.review?.status !== "accepted" || !prior.review.by?.trim())
200
+ continue;
201
+ evidence.length = 0;
202
+ evidence.push(...common);
203
+ files.clear();
204
+ const request = read(`${control.id}.request.json`), result = read(`${control.id}.result.json`);
205
+ if (request.prompt !== control.prompt || request.sha256 !== hash(control.prompt) || result.expected !== control.expected ||
206
+ result.judgeFormat !== "json" || typeof result.raw !== "string" || !Array.isArray(result.rawReplies) ||
207
+ result.rawReplies.length < 1 || result.rawReplies.length > 2 || !Array.isArray(result.prompts) ||
208
+ result.prompts.length !== result.rawReplies.length || result.prompts[0] !== control.prompt ||
209
+ result.raw !== result.rawReplies.at(-1))
210
+ throw new Error(`Invalid prior control evidence: ${control.id} in ${directory}`);
211
+ for (let index = 0; index < result.rawReplies.length; index++) {
212
+ const sent = read(`${control.id}.attempt-${index + 1}.request.json`);
213
+ const reply = read(`${control.id}.attempt-${index + 1}.reply.json`);
214
+ if (typeof result.rawReplies[index] !== "string" || typeof result.prompts[index] !== "string" ||
215
+ sent.prompt !== result.prompts[index] || sent.sha256 !== hash(sent.prompt) ||
216
+ reply.reply !== result.rawReplies[index] || reply.sha256 !== hash(reply.reply))
217
+ throw new Error(`Prior request/reply changed: ${control.id} in ${directory}`);
218
+ }
219
+ const scenario = current.spec.scenarios.find(scenario => scenario.id === control.scenarioId);
220
+ if (result.prompts.length === 2) {
221
+ let validationError;
222
+ try {
223
+ parseStructuredJudgeReply(result.rawReplies[0], scenario.checklist.length);
224
+ }
225
+ catch (error) {
226
+ validationError = error.message;
227
+ }
228
+ // Bind the validation-guided retry as well as the initial request; arbitrary follow-ups are not equivalent.
229
+ const retry = `${control.prompt}\n\nYour reply was not valid: ${JSON.stringify(validationError)}; reply with only the JSON object.`;
230
+ if (validationError === undefined || /^\[judge error:/i.test(result.rawReplies[0].trim()) || result.prompts[1] !== retry) {
231
+ throw new Error(`Prior retry prompt is not equivalent: ${control.id} in ${directory}`);
232
+ }
233
+ }
234
+ const parsed = parseStructuredJudgeReply(result.raw, scenario.checklist.length);
235
+ const consistent = parsed.verdict === "PASS" ? parsed.criteria.every(vote => vote.verdict === "PASS") : parsed.criteria.some(vote => vote.verdict === "FAIL");
236
+ if (result.suspect || !consistent || parsed.verdict !== result.verdict || parsed.reason !== result.reason ||
237
+ JSON.stringify(parsed.criteria) !== JSON.stringify(result.criteria))
238
+ throw new Error(`Invalid prior judgment: ${control.id} in ${directory}`);
239
+ if (retained.has(control.id) && retained.get(control.id).result.verdict !== result.verdict) {
240
+ plan.issues.push(`${control.id}: prior judgments disagree; inspect them or select a fresh run explicitly`);
241
+ continue;
242
+ }
243
+ if (!retained.has(control.id)) {
244
+ // Freeze the exact selected records now; later writes cannot substitute changed source bytes.
245
+ retained.set(control.id, { result, files: new Map(files) });
246
+ const row = plan.controls.find(row => row.id === control.id);
247
+ row.action = "reuse";
248
+ row.source = { directory, manifestSha256: summary.manifestSha256, artifacts: evidence.slice() };
249
+ }
250
+ }
251
+ }
252
+ plan.reused = retained.size;
253
+ plan.judgeCallsPlanned -= retained.size;
254
+ plan.ready = plan.issues.length === 0;
255
+ return { current, selected, plan, retained };
256
+ }
257
+ /** Preview eligible historical judgments. Reuse is not a fresh stability measurement. */
258
+ export function planCalibration(loaded, options) {
259
+ return calibrationPlan(loaded, options).plan;
260
+ }
261
+ /** Explicit Pi-backed judging; semantic mismatches continue, infrastructure errors stop the remaining calls. */
262
+ export async function runCalibration(loaded, options) {
263
+ const { current, selected, plan, retained } = calibrationPlan(loaded, options);
264
+ if (!plan.ready)
265
+ throw new Error(`Calibration is not ready: ${plan.issues.join("; ")}`);
154
266
  if (options.adapter.name !== "pi")
155
267
  throw new Error("Calibration requires Pi; direct provider adapters are unsupported");
156
268
  assertJudgeAllowed(options.judge, { source: "calibration judge", allowMetered: options.allowMeteredJudge });
157
- if (!await options.adapter.available())
269
+ if (plan.judgeCallsPlanned && !await options.adapter.available())
158
270
  throw new Error("Pi is unavailable; no judge calls made");
159
271
  const output = resolve(options.output);
160
272
  mkdirSync(output, { mode: 0o700 }); // exclusive new directory, including when a previous run was interrupted
161
- const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
162
- save("inputs.json", { manifestSha256: current.manifestSha256, judge: options.judge, controls: current.controls });
163
- const summary = {
164
- schema: 1, purpose: "judge-calibration", modelQualityScore: false, manifestSha256: current.manifestSha256,
165
- judge: options.judge, cases: current.controls.length, completed: 0, matched: 0, judgeCalls: 0,
166
- status: "matched", falsePasses: [], falseFailures: [], errors: [], unrun: [], output,
167
- };
168
- for (const [index, control] of current.controls.entries()) {
169
- options.onProgress?.({ id: control.id, index, total: current.controls.length });
170
- const scenario = current.spec.scenarios.find(s => s.id === control.scenarioId);
171
- save(`${control.id}.request.json`, { prompt: control.prompt, sha256: hash(control.prompt) });
172
- let attempt = 0;
173
- const observedAdapter = {
174
- name: "pi", available: () => options.adapter.available(), run: request => options.adapter.run(request),
175
- judge: async (request) => {
176
- const number = ++attempt;
177
- save(`${control.id}.attempt-${number}.request.json`, { prompt: request.prompt, sha256: hash(request.prompt) });
178
- summary.judgeCalls++;
179
- const reply = await options.adapter.judge(request);
180
- save(`${control.id}.attempt-${number}.reply.json`, { reply, sha256: hash(reply) });
181
- return reply;
182
- },
273
+ return withResourceMonitoring(output, async () => {
274
+ throwIfProcessAborted();
275
+ const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
276
+ save("inputs.json", { manifestSha256: current.manifestSha256, judge: options.judge, controls: selected });
277
+ save("plan.json", plan);
278
+ const summary = {
279
+ schema: 1, purpose: "judge-calibration", modelQualityScore: false, manifestSha256: current.manifestSha256,
280
+ judge: options.judge, cases: selected.length, completed: 0, matched: 0, judgeCalls: 0,
281
+ calibrationScope: "answer-judge", executionGates: "not-evaluated", availableCases: current.controls.length,
282
+ partial: plan.partial, disagreements: [], reused: [],
283
+ status: "matched", falsePasses: [], falseFailures: [], errors: [], unrun: [], output,
183
284
  };
184
- let result;
185
- try {
186
- result = await judgeInWorkspace(observedAdapter, options.judge, control.prompt, current.specDir, scenario.checklist.length);
285
+ for (const [index, control] of selected.entries()) {
286
+ throwIfProcessAborted();
287
+ const scenario = current.spec.scenarios.find(s => s.id === control.scenarioId);
288
+ let attempt = 0;
289
+ const observedAdapter = {
290
+ name: "pi", available: () => options.adapter.available(), run: request => options.adapter.run(request),
291
+ judge: async (request) => {
292
+ throwIfProcessAborted();
293
+ const number = ++attempt;
294
+ save(`${control.id}.attempt-${number}.request.json`, { prompt: request.prompt, sha256: hash(request.prompt) });
295
+ summary.judgeCalls++;
296
+ const reply = await options.adapter.judge(request);
297
+ save(`${control.id}.attempt-${number}.reply.json`, { reply, sha256: hash(reply) });
298
+ throwIfProcessAborted();
299
+ return reply;
300
+ },
301
+ };
302
+ let result;
303
+ try {
304
+ const prior = retained.get(control.id);
305
+ if (prior) {
306
+ const source = plan.controls.find(row => row.id === control.id).source;
307
+ result = prior.result;
308
+ // Historical records remain byte-identical and self-contained, never counted as fresh calls.
309
+ for (const [name, bytes] of prior.files)
310
+ writeFileSync(join(output, name), bytes, { flag: "wx", mode: 0o600 });
311
+ save(`${control.id}.reuse.json`, source);
312
+ summary.reused.push({ id: control.id, source });
313
+ }
314
+ else {
315
+ save(`${control.id}.request.json`, { prompt: control.prompt, sha256: hash(control.prompt) });
316
+ options.onProgress?.({ id: control.id, index, total: selected.length });
317
+ result = await judgeInWorkspace(observedAdapter, options.judge, control.prompt, current.specDir, scenario.checklist.length);
318
+ }
319
+ }
320
+ catch (error) {
321
+ if (isProcessControlError(error))
322
+ throw error;
323
+ throwIfProcessAborted();
324
+ save(`${control.id}.error.json`, { status: "ERROR", message: String(error) });
325
+ summary.errors.push(control.id);
326
+ summary.unrun = selected.slice(index + 1).map(c => c.id);
327
+ break;
328
+ }
329
+ if (!retained.has(control.id))
330
+ save(`${control.id}.result.json`, { expected: control.expected, ...result });
331
+ if ((result.verdict !== "PASS" && result.verdict !== "FAIL") || result.suspect) {
332
+ summary.errors.push(control.id);
333
+ summary.unrun = selected.slice(index + 1).map(c => c.id);
334
+ break;
335
+ }
336
+ summary.completed++;
337
+ if (result.verdict === control.expected)
338
+ summary.matched++;
339
+ else {
340
+ summary.disagreements.push({ id: control.id, expected: control.expected, observed: result.verdict });
341
+ if (result.verdict === "PASS")
342
+ summary.falsePasses.push(control.id);
343
+ else
344
+ summary.falseFailures.push(control.id);
345
+ }
187
346
  }
188
- catch (error) {
189
- save(`${control.id}.error.json`, { status: "ERROR", message: String(error) });
190
- summary.errors.push(control.id);
191
- summary.unrun = current.controls.slice(index + 1).map(c => c.id);
192
- break;
193
- }
194
- save(`${control.id}.result.json`, { expected: control.expected, ...result });
195
- if ((result.verdict !== "PASS" && result.verdict !== "FAIL") || result.suspect) {
196
- summary.errors.push(control.id);
197
- summary.unrun = current.controls.slice(index + 1).map(c => c.id);
198
- break;
199
- }
200
- summary.completed++;
201
- if (result.verdict === control.expected)
202
- summary.matched++;
203
- else if (result.verdict === "PASS")
204
- summary.falsePasses.push(control.id);
205
- else
206
- summary.falseFailures.push(control.id);
207
- }
208
- summary.status = summary.errors.length ? "error" : summary.matched === summary.cases ? "matched" : "mismatch";
209
- save("summary.json", summary);
210
- return summary;
347
+ summary.status = summary.errors.length ? "error" : summary.matched === summary.cases ? "matched" : "mismatch";
348
+ save("summary.json", summary);
349
+ return summary;
350
+ });
211
351
  }
212
352
  //# sourceMappingURL=calibration.js.map
package/dist/canary.d.ts CHANGED
@@ -1,44 +1,46 @@
1
1
  import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
2
- /**
3
- * The probe target: the longest `## ` heading in the skill's body.
4
- *
5
- * Body-only by construction — the frontmatter `description` is always in context
6
- * under progressive disclosure, so anything quotable from it would pass against a
7
- * model that never read the instructions. Longest rather than first because the
8
- * check is "did you see this text", and `## Overview` is guessable while
9
- * `## Refuse a metered judge, whatever chose it` is not.
10
- *
11
- * Null when the body has no `## ` heading: there is then nothing to ask for that a
12
- * plausible-sounding answer couldn't fake, and a canary that can be bluffed is
13
- * worse than none.
14
- */
2
+ /** A body-only quote target. Matching it is an observation, not proof of loading. */
15
3
  export declare function deliveryAnchor(skillMd: string): string | null;
4
+ export interface CanaryObservations {
5
+ /** Local readability does not establish what Pi advertised to the subject. */
6
+ localSkillAvailable: boolean;
7
+ loadingRequested: boolean;
8
+ /** Successful completion of a read of the exact selected SKILL.md, if observable. */
9
+ successfulSkillRead: "observed" | "not-observed" | "unobserved";
10
+ /** Only the final assistant response is checked, never tool output or prompts. */
11
+ returnedAnchor: boolean | null;
12
+ /** The probe explicitly requests loading, so it cannot measure autonomous selection. */
13
+ autonomousSelection: "untested";
14
+ executionError?: string;
15
+ }
16
16
  export interface CanaryResult {
17
- /** `pass` = the anchor came back; `fail` = it did not; `skipped` = nothing safe to probe for. */
17
+ /** `fail` is an inconclusive delivery check, not evidence that the skill is unavailable. */
18
18
  status: "pass" | "fail" | "skipped";
19
- /** What the probe looked for (null when skipped). */
20
19
  anchor: string | null;
21
- /** Why it was skipped, or what the model said instead (trimmed) — for the log and the journal. */
22
20
  detail: string;
21
+ observations: CanaryObservations;
22
+ probeCalls: 0 | 1;
23
23
  }
24
- export declare function canaryPrompt(skillName: string, anchor: string): string;
24
+ /**
25
+ * Ask Pi to load the named skill through its advertised discovery mechanism.
26
+ * Neither the expected quote nor a fallback file path is disclosed: a supplied
27
+ * path would let the probe bypass missing skill discovery entirely.
28
+ */
29
+ export declare function canaryPrompt(skillName: string, _anchor?: string): string;
25
30
  export interface CanaryOptions {
26
31
  adapter: HarnessAdapter;
27
32
  model: ModelRef;
28
33
  skillDir: string;
29
34
  skillName: string;
30
- /** Neutral cwd, same as a scenario gets — the probe must not see a repo either. */
35
+ /** Neutral cwd, same as a scenario gets. */
31
36
  cwd: string;
32
37
  }
33
38
  /**
34
- * Run the probe in green mode and report whether the skill body was reachable.
35
- *
36
- * Never throws for a model-side outcome: an empty or off-format reply is a `fail`
37
- * with the reply in `detail`, because "the harness answered without the skill" and
38
- * "the model said something odd" are both reasons not to spend a wave. An adapter
39
- * that throws (pi missing, the skill-dir tripwire) is left to propagate — those are
40
- * setup errors with their own messages.
39
+ * One optional subject invocation, with read-only tools, before a green wave.
40
+ * A successful structured probe observes an instructed read and returned content;
41
+ * it does not establish autonomous skill selection in any later scenario. A
42
+ * failed quote is inconclusive, never proof that every later scenario is naked.
41
43
  */
42
44
  export declare function runDeliveryCanary(opts: CanaryOptions): Promise<CanaryResult>;
43
- /** The abort message for a failed canary: what was measured, and what to do instead. */
45
+ /** Stop the requested wave without converting an inconclusive probe into a claim. */
44
46
  export declare function canaryFailure(skillName: string, result: CanaryResult, cliVersion: string | null): string;