@skill-harness/core 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sources.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFileSync, readdirSync } from "node:fs";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
4
  import yaml from "js-yaml";
5
+ import { resolveJudgeReferences } from "./judge-references.js";
5
6
  import { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
6
7
  export { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
7
8
  /**
@@ -170,7 +171,7 @@ function walk(dir, prefix = "") {
170
171
  * transcripts are actually invalid.
171
172
  */
172
173
  function facets(s) {
173
- const { id, title, critical, mode, turns, checklist, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
174
+ const { id, title, critical, mode, turns, checklist, judgeContext, judgeEvidence, judgeReferences, fixture, assert, traceAssert, workspace, remote, systemPromptFile, extensions, tools, reps, passThreshold, covers: _coversIsMetadata, ...restScenario } = s;
174
175
  const _scenarioExhaustive = restScenario;
175
176
  void _scenarioExhaustive;
176
177
  // `covers` is destructured into a discard on purpose, and this comment is the
@@ -206,8 +207,15 @@ function facets(s) {
206
207
  id, mode, turns, workspace, remote, systemPromptFile ?? null,
207
208
  fixture ?? null, vitest ?? null, post_test ?? null,
208
209
  ...(extensions ? [extensions] : []),
210
+ ...(tools !== undefined ? [["tools", tools]] : []),
211
+ ]),
212
+ // Judge-only reference text changes grading, not what the subject received.
213
+ // Preserve historical hashes when the optional field is absent.
214
+ rubric: JSON.stringify([id, title, checklist,
215
+ ...(judgeContext !== undefined ? [["judge_context", judgeContext]] : []),
216
+ ...(judgeEvidence !== undefined ? [["judge_evidence", judgeEvidence]] : []),
217
+ ...(judgeReferences !== undefined ? [["judge_references", judgeReferences]] : []),
209
218
  ]),
210
- rubric: JSON.stringify([id, title, checklist]),
211
219
  policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
212
220
  // Same rule as `stimulus` above: conditional, so a needle-gated scenario that
213
221
  // declares no trace assertions keeps the digest it was published with.
@@ -227,8 +235,15 @@ function sha(canonical) {
227
235
  export function stimulusDigest(s) {
228
236
  return sha(facets(s).stimulus);
229
237
  }
230
- export function rubricDigest(s) {
231
- return sha(facets(s).rubric);
238
+ export function rubricDigest(s, specDir, references) {
239
+ if (!s.judgeReferences?.length)
240
+ return sha(facets(s).rubric);
241
+ if (!references && !specDir)
242
+ throw new Error("A specification directory is required to digest selected judge references");
243
+ const selected = references ?? resolveJudgeReferences(s, specDir);
244
+ if (selected.length !== s.judgeReferences.length || selected.some((ref, index) => ref.file !== s.judgeReferences[index]))
245
+ throw new Error("Resolved judge references do not match the scenario selection");
246
+ return sha(JSON.stringify([facets(s).rubric, selected.map(({ file, sha256 }) => ({ file, sha256 }))]));
232
247
  }
233
248
  export function policyDigest(s) {
234
249
  return sha(facets(s).policy);
@@ -312,7 +327,12 @@ export function sourceHashes(ctx) {
312
327
  // single key could only ever name the most expensive one. The legacy
313
328
  // `scenario:<id>` key is deliberately NOT written any more — see scenarioDigest.
314
329
  hashes[STIMULUS_PREFIX + s.id] = stimulusDigest(s);
315
- hashes[RUBRIC_PREFIX + s.id] = rubricDigest(s);
330
+ try {
331
+ hashes[RUBRIC_PREFIX + s.id] = rubricDigest(s, ctx.specDir, ctx.judgeReferences?.[s.id]);
332
+ }
333
+ catch {
334
+ hashes[RUBRIC_PREFIX + s.id] = UNREADABLE;
335
+ }
316
336
  hashes[POLICY_PREFIX + s.id] = policyDigest(s);
317
337
  const gates = gatesDigest(s);
318
338
  if (gates !== null)
@@ -384,7 +404,12 @@ export function currentHashFor(key, ctx) {
384
404
  // scenario is a reshape, not staleness, on every kind.
385
405
  const facetResolvers = [
386
406
  [STIMULUS_PREFIX, stimulusDigest],
387
- [RUBRIC_PREFIX, rubricDigest],
407
+ [RUBRIC_PREFIX, s => { try {
408
+ return rubricDigest(s, ctx.specDir);
409
+ }
410
+ catch {
411
+ return null;
412
+ } }],
388
413
  [POLICY_PREFIX, policyDigest],
389
414
  [GATES_PREFIX, gatesDigest],
390
415
  [SCENARIO_PREFIX, scenarioDigest],
package/dist/spec.d.ts CHANGED
@@ -31,6 +31,12 @@ export interface Scenario {
31
31
  mode: ScenarioMode;
32
32
  turns: string[];
33
33
  checklist: string[];
34
+ /** Explicit author-supplied reference text for the judge only; never a subject prompt or file path. */
35
+ judgeContext?: string;
36
+ /** Judge evidence is independent of whether a fixture workspace is used. */
37
+ judgeEvidence?: "response" | "code";
38
+ /** Explicit judge-only files, relative to the specification directory. */
39
+ judgeReferences?: string[];
34
40
  fixture?: string;
35
41
  assert?: ScenarioAssert;
36
42
  /**
@@ -54,6 +60,8 @@ export interface Scenario {
54
60
  * silently become part of the test.
55
61
  */
56
62
  extensions?: string[];
63
+ /** Explicit Pi tool filter; an empty list disables tools. */
64
+ tools?: string[];
57
65
  /**
58
66
  * `covers`: instruction sections this scenario is declared to exercise, e.g.
59
67
  * `SKILL.md#core-principle`.
package/dist/spec.js CHANGED
@@ -191,6 +191,33 @@ export function parseSpec(text, file) {
191
191
  workspace: "none",
192
192
  remote: false,
193
193
  };
194
+ const envTools = s.env && typeof s.env === "object" ? s.env.tools : undefined;
195
+ if (envTools !== undefined) {
196
+ if (!Array.isArray(envTools) || envTools.some(tool => typeof tool !== "string" || !/^[A-Za-z][A-Za-z0-9_:/.-]*$/.test(tool)) || new Set(envTools).size !== envTools.length) {
197
+ throw new SpecError(`scenario \`${id}\` env.tools must be a list of unique Pi tool names`, file);
198
+ }
199
+ scenario.tools = envTools;
200
+ }
201
+ if (s.judge_context !== undefined) {
202
+ if (typeof s.judge_context !== "string" || s.judge_context.trim() === "" || Buffer.byteLength(s.judge_context, "utf8") > 65_536) {
203
+ throw new SpecError(`scenario \`${id}\` \`judge_context\` must be a non-empty string of at most 65536 UTF-8 bytes`, file);
204
+ }
205
+ scenario.judgeContext = s.judge_context;
206
+ }
207
+ if (s.judge_evidence !== undefined) {
208
+ if (s.judge_evidence !== "response" && s.judge_evidence !== "code") {
209
+ throw new SpecError(`scenario \`${id}\` \`judge_evidence\` must be response or code`, file);
210
+ }
211
+ scenario.judgeEvidence = s.judge_evidence;
212
+ }
213
+ if (s.judge_references !== undefined) {
214
+ assertStringList(s.judge_references, id, "judge_references", file);
215
+ if (s.judge_references.length > 16 || new Set(s.judge_references).size !== s.judge_references.length ||
216
+ s.judge_references.some(path => !path || /[\\\\:\x00-\x1f]/.test(path) || path.startsWith("/") || path.split("/").some(part => !part || part === "." || part === ".."))) {
217
+ throw new SpecError(`scenario \`${id}\` \`judge_references\` requires at most 16 unique relative POSIX file paths without traversal`, file);
218
+ }
219
+ scenario.judgeReferences = s.judge_references;
220
+ }
194
221
  // Output and trace assertions are legal for inline AND seeded scenarios.
195
222
  if (s.assert !== undefined && (s.assert === null || typeof s.assert !== "object" || Array.isArray(s.assert))) {
196
223
  throw new SpecError(`scenario \`${id}\` \`assert\` must be a mapping`, file);
@@ -33,6 +33,8 @@ export interface RequireCall {
33
33
  tool: string;
34
34
  count?: CountConstraint;
35
35
  args?: Record<string, ArgPredicate>;
36
+ /** Optional tool-reported result; omission preserves invocation-only matching. */
37
+ outcome?: "success" | "error";
36
38
  }
37
39
  export interface ForbidCall {
38
40
  tool: string;
@@ -43,10 +43,15 @@ export function evaluateTraceGates(assert, trace) {
43
43
  assertions.push({ kind: "trace_evidence", status: "ERROR", detail: error });
44
44
  }
45
45
  for (const req of assert.require_calls ?? []) {
46
- const matched = trace.tool_calls.filter((c) => c.name === req.tool && argsMatch(c, req.args));
46
+ const candidates = trace.tool_calls.filter((c) => c.name === req.tool && argsMatch(c, req.args));
47
+ if (req.outcome && candidates.some(c => !Number.isInteger(c.completionIndex) || c.completionIndex < 0 || typeof c.isError !== "boolean")) {
48
+ assertions.push({ kind: "require_call", status: "ERROR", detail: `cannot establish ${req.outcome} outcome for \`${req.tool}\`: completion evidence is missing` });
49
+ continue;
50
+ }
51
+ const matched = req.outcome ? candidates.filter(c => c.isError === (req.outcome === "error")) : candidates;
47
52
  const min = req.count?.min ?? 1;
48
53
  const max = req.count?.max;
49
- const described = describeArgs(req.args);
54
+ const described = describeArgs(req.args) + (req.outcome ? ` with tool-reported ${req.outcome}` : "");
50
55
  if (matched.length < min) {
51
56
  assertions.push({
52
57
  kind: "require_call",
@@ -354,8 +359,13 @@ export function parseTraceAssert(raw, ctx) {
354
359
  req.count = parseCount(entry.count, `${ctx}: \`require_calls[${i}].count\``);
355
360
  if (entry.args !== undefined)
356
361
  req.args = parseArgs(entry.args, `${ctx}: \`require_calls[${i}].args\``);
362
+ if (entry.outcome !== undefined) {
363
+ if (entry.outcome !== "success" && entry.outcome !== "error")
364
+ throw new Error(`${ctx}: \`require_calls[${i}].outcome\` must be success or error`);
365
+ req.outcome = entry.outcome;
366
+ }
357
367
  for (const key of Object.keys(entry)) {
358
- if (!["tool", "count", "args"].includes(key)) {
368
+ if (!["tool", "count", "args", "outcome"].includes(key)) {
359
369
  throw new Error(`${ctx}: unknown key \`${key}\` in \`require_calls[${i}]\``);
360
370
  }
361
371
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -235,6 +235,16 @@
235
235
  "properties": {
236
236
  "judgeFormat": { "enum": ["json", "prose"] },
237
237
  "judgeRetries": { "type": "integer", "const": 1 },
238
+ "input": {
239
+ "type": "object", "additionalProperties": false,
240
+ "required": ["artifact", "sha256", "execution"],
241
+ "properties": {
242
+ "artifact": { "type": "string", "pattern": "^judge-input\\.[a-f0-9]{64}\\.txt$" },
243
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
244
+ "execution": { "enum": ["verified", "unknown"] },
245
+ "trace_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
246
+ }
247
+ },
238
248
  "ordinal": {
239
249
  "type": "integer",
240
250
  "minimum": 1,
@@ -247,6 +247,16 @@
247
247
  "properties": {
248
248
  "judgeFormat": { "enum": ["json", "prose"] },
249
249
  "judgeRetries": { "type": "integer", "const": 1 },
250
+ "input": {
251
+ "type": "object", "additionalProperties": false,
252
+ "required": ["artifact", "sha256", "execution"],
253
+ "properties": {
254
+ "artifact": { "type": "string", "pattern": "^judge-input\\.[a-f0-9]{64}\\.txt$" },
255
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
256
+ "execution": { "enum": ["verified", "unknown"] },
257
+ "trace_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
258
+ }
259
+ },
250
260
  "ordinal": {
251
261
  "type": "integer",
252
262
  "minimum": 1,
@@ -102,6 +102,22 @@
102
102
  },
103
103
  "minItems": 1
104
104
  },
105
+ "judge_context": {
106
+ "type": "string",
107
+ "minLength": 1,
108
+ "maxLength": 65536,
109
+ "pattern": "\\S",
110
+ "description": "Explicit author-supplied reference text for the judge only. The runtime also enforces at most 65536 UTF-8 bytes. Not a file path; no files are loaded. Changes stale the rubric only."
111
+ },
112
+ "judge_evidence": {
113
+ "enum": ["response", "code"],
114
+ "description": "Evidence emphasis, independent of fixture workspace. Response tasks never receive code-diff priority guidance."
115
+ },
116
+ "judge_references": {
117
+ "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true,
118
+ "items": { "type": "string", "minLength": 1 },
119
+ "description": "Explicit relative POSIX files below the specification directory, judge-only, collectively at most 65536 UTF-8 bytes. Runtime rejects traversal, escaping symlinks and binary data."
120
+ },
105
121
  "covers": {
106
122
  "type": "array",
107
123
  "items": {
@@ -133,6 +149,11 @@
133
149
  "items": {
134
150
  "type": "string"
135
151
  }
152
+ },
153
+ "tools": {
154
+ "type": "array", "uniqueItems": true,
155
+ "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_:/.-]*$" },
156
+ "description": "Explicit Pi tool filter. Empty list disables all tools. Not an OS sandbox."
136
157
  }
137
158
  }
138
159
  },