@skill-harness/core 0.20.0 → 0.21.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/discover.d.ts +4 -2
- package/dist/discover.js +26 -5
- package/dist/instruction-coverage.d.ts +2 -0
- package/dist/instruction-coverage.js +1 -1
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +11 -6
- package/dist/regate.js +1 -0
- package/dist/regrade.js +1 -0
- package/dist/rescore.js +1 -0
- package/dist/restamp.d.ts +2 -0
- package/dist/restamp.js +3 -2
- package/dist/results.d.ts +2 -0
- package/dist/results.js +1 -0
- package/dist/run.d.ts +4 -0
- package/dist/run.js +5 -3
- package/dist/sources.d.ts +2 -0
- package/dist/sources.js +4 -0
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +6 -0
- package/schemas/results-v3.schema.json +6 -0
package/dist/discover.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export interface DiscoveredSkill {
|
|
2
2
|
name: string;
|
|
3
3
|
dir: string;
|
|
4
|
+
testsDir: string;
|
|
4
5
|
hasSpec: boolean;
|
|
5
6
|
specPath: string;
|
|
7
|
+
specSource: "skills" | "specs" | "both" | null;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
10
|
* Scan a skills root. A "skill" is any immediate subdirectory containing a
|
|
@@ -16,10 +18,10 @@ export interface DiscoveredSkill {
|
|
|
16
18
|
* and a normal answer. The adapter refuses such a path too (see requireSkillDir),
|
|
17
19
|
* but the honest fix is here, where the path is built.
|
|
18
20
|
*/
|
|
19
|
-
export declare function discover(root: string): DiscoveredSkill[];
|
|
21
|
+
export declare function discover(root: string, specsRoot?: string): DiscoveredSkill[];
|
|
20
22
|
/**
|
|
21
23
|
* Resolve a single skill by name; throws a helpful error if absent or specless.
|
|
22
24
|
* A directory that exists but lacks a SKILL.md gets a specific error (rather than
|
|
23
25
|
* the generic "no skill") so callers don't reimplement the SKILL.md existence check.
|
|
24
26
|
*/
|
|
25
|
-
export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
|
|
27
|
+
export declare function resolveSkill(root: string, name: string, specsRoot?: string): DiscoveredSkill;
|
package/dist/discover.js
CHANGED
|
@@ -12,11 +12,15 @@ import { join, resolve } from "node:path";
|
|
|
12
12
|
* and a normal answer. The adapter refuses such a path too (see requireSkillDir),
|
|
13
13
|
* but the honest fix is here, where the path is built.
|
|
14
14
|
*/
|
|
15
|
-
export function discover(root) {
|
|
15
|
+
export function discover(root, specsRoot) {
|
|
16
16
|
const absRoot = resolve(root);
|
|
17
17
|
if (!existsSync(absRoot) || !statSync(absRoot).isDirectory()) {
|
|
18
18
|
throw new Error(`skills root is not a directory: ${root}`);
|
|
19
19
|
}
|
|
20
|
+
const absSpecsRoot = specsRoot ? resolve(specsRoot) : undefined;
|
|
21
|
+
if (absSpecsRoot && (!existsSync(absSpecsRoot) || !statSync(absSpecsRoot).isDirectory())) {
|
|
22
|
+
throw new Error(`specs root is not a directory: ${specsRoot}`);
|
|
23
|
+
}
|
|
20
24
|
const skills = [];
|
|
21
25
|
for (const name of readdirSync(absRoot)) {
|
|
22
26
|
if (name.startsWith("."))
|
|
@@ -26,8 +30,22 @@ export function discover(root) {
|
|
|
26
30
|
continue;
|
|
27
31
|
if (!existsSync(join(dir, "SKILL.md")))
|
|
28
32
|
continue;
|
|
29
|
-
const
|
|
30
|
-
|
|
33
|
+
const localSpecPath = join(dir, "tests", "specification.yaml");
|
|
34
|
+
const overlayTestsDir = absSpecsRoot ? join(absSpecsRoot, name, "tests") : join(dir, "tests");
|
|
35
|
+
const overlaySpecPath = join(overlayTestsDir, "specification.yaml");
|
|
36
|
+
const local = existsSync(localSpecPath);
|
|
37
|
+
const overlay = Boolean(absSpecsRoot && existsSync(overlaySpecPath));
|
|
38
|
+
const specSource = local && overlay ? "both" : overlay ? "specs" : local ? "skills" : null;
|
|
39
|
+
skills.push({
|
|
40
|
+
name,
|
|
41
|
+
dir,
|
|
42
|
+
// An explicit --specs is authoritative. The local path is inspected only so
|
|
43
|
+
// list can report it and duplicate specs can be refused rather than merged.
|
|
44
|
+
testsDir: overlayTestsDir,
|
|
45
|
+
hasSpec: absSpecsRoot ? overlay && !local : local,
|
|
46
|
+
specPath: overlaySpecPath,
|
|
47
|
+
specSource,
|
|
48
|
+
});
|
|
31
49
|
}
|
|
32
50
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
33
51
|
return skills;
|
|
@@ -37,8 +55,8 @@ export function discover(root) {
|
|
|
37
55
|
* A directory that exists but lacks a SKILL.md gets a specific error (rather than
|
|
38
56
|
* the generic "no skill") so callers don't reimplement the SKILL.md existence check.
|
|
39
57
|
*/
|
|
40
|
-
export function resolveSkill(root, name) {
|
|
41
|
-
const skill = discover(root).find((s) => s.name === name);
|
|
58
|
+
export function resolveSkill(root, name, specsRoot) {
|
|
59
|
+
const skill = discover(root, specsRoot).find((s) => s.name === name);
|
|
42
60
|
if (!skill) {
|
|
43
61
|
const dir = join(resolve(root), name);
|
|
44
62
|
if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
|
|
@@ -46,6 +64,9 @@ export function resolveSkill(root, name) {
|
|
|
46
64
|
}
|
|
47
65
|
throw new Error(`no skill \`${name}\` under ${root}`);
|
|
48
66
|
}
|
|
67
|
+
if (skill.specSource === "both") {
|
|
68
|
+
throw new Error(`skill \`${name}\` has a specification.yaml in both --skills and --specs; remove one (specs are not merged)`);
|
|
69
|
+
}
|
|
49
70
|
return skill;
|
|
50
71
|
}
|
|
51
72
|
//# sourceMappingURL=discover.js.map
|
|
@@ -85,6 +85,8 @@ export interface CoverageOptions {
|
|
|
85
85
|
scenarios: Scenario[];
|
|
86
86
|
/** Instruction files to report on even if nothing references them. */
|
|
87
87
|
baseFiles?: string[];
|
|
88
|
+
/** Resolve selected spec-relative references from another root (for split skill/spec repos). */
|
|
89
|
+
fileOverrides?: Record<string, string>;
|
|
88
90
|
}
|
|
89
91
|
/**
|
|
90
92
|
* Build the coverage report.
|
|
@@ -115,7 +115,7 @@ export function computeCoverage(opts) {
|
|
|
115
115
|
const readSections = (file) => {
|
|
116
116
|
if (fileSections.has(file))
|
|
117
117
|
return fileSections.get(file);
|
|
118
|
-
const abs = isAbsolute(file) ? file : resolve(opts.specDir, file);
|
|
118
|
+
const abs = opts.fileOverrides?.[file] ?? (isAbsolute(file) ? file : resolve(opts.specDir, file));
|
|
119
119
|
if (!existsSync(abs))
|
|
120
120
|
return null;
|
|
121
121
|
const sections = parseSections(readFileSync(abs, "utf8"));
|
package/dist/lint.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export declare function failsGate(f: LintFinding): boolean;
|
|
|
26
26
|
* becomes a single `code:"spec"` finding. Returns ALL findings so the CLI can
|
|
27
27
|
* report every problem across every skill.
|
|
28
28
|
*/
|
|
29
|
-
export declare function lintSkill(skillDir: string): LintFinding[];
|
|
29
|
+
export declare function lintSkill(skillDir: string, testsDir?: string): LintFinding[];
|
|
30
30
|
/**
|
|
31
31
|
* All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
|
|
32
32
|
* Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
|
package/dist/lint.js
CHANGED
|
@@ -37,8 +37,8 @@ function isFile(p) {
|
|
|
37
37
|
* becomes a single `code:"spec"` finding. Returns ALL findings so the CLI can
|
|
38
38
|
* report every problem across every skill.
|
|
39
39
|
*/
|
|
40
|
-
export function lintSkill(skillDir) {
|
|
41
|
-
const specPath = join(
|
|
40
|
+
export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
|
|
41
|
+
const specPath = join(testsDir, "specification.yaml");
|
|
42
42
|
const findings = [];
|
|
43
43
|
let spec;
|
|
44
44
|
try {
|
|
@@ -55,7 +55,8 @@ export function lintSkill(skillDir) {
|
|
|
55
55
|
// kinds added after it shipped. Reported as a finding (so CI sees it) rather than
|
|
56
56
|
// thrown, because refusing to lint is worse than linting with a caveat, and it is
|
|
57
57
|
// one of the few ways this situation announces itself at all.
|
|
58
|
-
const
|
|
58
|
+
const resultsSkillDir = dirname(testsDir);
|
|
59
|
+
const stale = downgradeWarning(resultsSkillDir);
|
|
59
60
|
if (stale) {
|
|
60
61
|
findings.push({ skill, code: "consistency", message: stale.replace(/^warning: /, "") });
|
|
61
62
|
}
|
|
@@ -152,7 +153,11 @@ export function lintSkill(skillDir) {
|
|
|
152
153
|
// uncovered section is only reported by `coverage --strict`. Renaming a heading
|
|
153
154
|
// is the usual cause, so the finding names the near-misses.
|
|
154
155
|
if (s.covers?.length) {
|
|
155
|
-
const report = computeCoverage({
|
|
156
|
+
const report = computeCoverage({
|
|
157
|
+
specDir,
|
|
158
|
+
scenarios: [s],
|
|
159
|
+
fileOverrides: { "../SKILL.md": join(skillDir, "SKILL.md") },
|
|
160
|
+
});
|
|
156
161
|
for (const b of report.broken) {
|
|
157
162
|
const hint = b.didYouMean.length ? ` — did you mean ${b.didYouMean.map((x) => `#${x}`).join(", ")}?` : "";
|
|
158
163
|
findings.push({
|
|
@@ -183,7 +188,7 @@ export function lintSkill(skillDir) {
|
|
|
183
188
|
// malformed fields — e.g. `scenarios: null`) is caught and surfaces as a `consistency`
|
|
184
189
|
// finding instead of throwing (lintSkill never throws) or being silently dropped (a
|
|
185
190
|
// broken committed artifact must fail the gate, not pass it).
|
|
186
|
-
const resultsRoot = join(
|
|
191
|
+
const resultsRoot = join(testsDir, "results");
|
|
187
192
|
for (const runDir of enumerateRunDirs(resultsRoot)) {
|
|
188
193
|
try {
|
|
189
194
|
const raw = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
|
|
@@ -316,7 +321,7 @@ export function lintSkill(skillDir) {
|
|
|
316
321
|
// is on disk. Wrapped: lintSkill must never throw, and a stability read touches every
|
|
317
322
|
// run file in the tree.
|
|
318
323
|
try {
|
|
319
|
-
for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(
|
|
324
|
+
for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(resultsSkillDir), spec))) {
|
|
320
325
|
findings.push({
|
|
321
326
|
skill, scenario: cell.id, code: "stability", severity: "info",
|
|
322
327
|
message: `${cell.tag} mode=${cell.mode}: ${stabilityNote(cell)}`,
|
package/dist/regate.js
CHANGED
|
@@ -284,6 +284,7 @@ export async function regateRun(opts) {
|
|
|
284
284
|
// Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
|
|
285
285
|
// and policy were not re-decided here, so their hashes stay exactly as recorded.
|
|
286
286
|
source_hashes: refreshGateHashes(prev.source_hashes, targets),
|
|
287
|
+
source_hash_roots: prev.source_hash_roots,
|
|
287
288
|
scenarios,
|
|
288
289
|
}, ctx);
|
|
289
290
|
appendJournal(opts.runDir, {
|
package/dist/regrade.js
CHANGED
|
@@ -230,6 +230,7 @@ export async function regradeRun(opts) {
|
|
|
230
230
|
// *stimulus* hashes stay" — see refreshRubricHashes.
|
|
231
231
|
partial: prev?.partial,
|
|
232
232
|
source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
|
|
233
|
+
source_hash_roots: prev?.source_hash_roots,
|
|
233
234
|
scenarios: scenarioResults,
|
|
234
235
|
}, ctx);
|
|
235
236
|
const g = results.effective_grade;
|
package/dist/rescore.js
CHANGED
|
@@ -98,6 +98,7 @@ export function rescoreRun(opts) {
|
|
|
98
98
|
// that is what makes `rescore` the honest remedy lint names for it. Stimulus,
|
|
99
99
|
// rubric and gate hashes are untouched: none of them was re-evaluated here.
|
|
100
100
|
source_hashes: refreshPolicyHashes(prev.source_hashes, opts.spec),
|
|
101
|
+
source_hash_roots: prev.source_hash_roots,
|
|
101
102
|
scenarios,
|
|
102
103
|
}, ctx);
|
|
103
104
|
appendJournal(opts.runDir, {
|
package/dist/restamp.d.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface RestampReport {
|
|
|
63
63
|
export interface RestampOptions {
|
|
64
64
|
/** Git ref holding the bytes the runs measured, for a tree that has already been edited. */
|
|
65
65
|
from?: string;
|
|
66
|
+
/** Tests/results location; defaults to <skillDir>/tests. */
|
|
67
|
+
testsDir?: string;
|
|
66
68
|
}
|
|
67
69
|
/** Upgrade every provable record under one skill's tests/results. */
|
|
68
70
|
export declare function restampSkill(skillDir: string, opts?: RestampOptions): RestampReport;
|
package/dist/restamp.js
CHANGED
|
@@ -101,7 +101,8 @@ function writeAtomically(path, text) {
|
|
|
101
101
|
/** Upgrade every provable record under one skill's tests/results. */
|
|
102
102
|
export function restampSkill(skillDir, opts = {}) {
|
|
103
103
|
const report = { runs: 0, upgraded: 0, unprovable: 0, unchanged: 0, partial: 0, added: [] };
|
|
104
|
-
const
|
|
104
|
+
const testsDir = opts.testsDir ?? join(skillDir, "tests");
|
|
105
|
+
const specPath = join(testsDir, "specification.yaml");
|
|
105
106
|
const spec = loadSpec(specPath);
|
|
106
107
|
const specDir = dirname(specPath);
|
|
107
108
|
// Only `system_prompt_file` paths, resolved from the CURRENT spec. The other
|
|
@@ -132,7 +133,7 @@ export function restampSkill(skillDir, opts = {}) {
|
|
|
132
133
|
currentPrompt,
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
|
-
for (const runDir of enumerateRunDirs(join(
|
|
136
|
+
for (const runDir of enumerateRunDirs(join(testsDir, "results"))) {
|
|
136
137
|
report.runs++;
|
|
137
138
|
let doc;
|
|
138
139
|
try {
|
package/dist/results.d.ts
CHANGED
|
@@ -238,6 +238,8 @@ export interface ResultsFile {
|
|
|
238
238
|
* simply don't carry it, and are never retroactively flagged.
|
|
239
239
|
*/
|
|
240
240
|
source_hashes?: Record<string, string>;
|
|
241
|
+
/** For split-root runs, identifies whether each hash came from --skills or --specs. */
|
|
242
|
+
source_hash_roots?: Record<string, "skills" | "specs">;
|
|
241
243
|
effective_grade: GradeSummary;
|
|
242
244
|
scenarios: ScenarioResult[];
|
|
243
245
|
/** Adapter-computed model-visible prompt observations. Required for schema v3. */
|
package/dist/results.js
CHANGED
|
@@ -212,6 +212,7 @@ export function finalizeResults(draft, ctx) {
|
|
|
212
212
|
...(draft.arm ? { arm: draft.arm } : {}),
|
|
213
213
|
...(draft.partial ? { partial: true } : {}),
|
|
214
214
|
...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
|
|
215
|
+
...(draft.source_hash_roots ? { source_hash_roots: draft.source_hash_roots } : {}),
|
|
215
216
|
effective_grade,
|
|
216
217
|
scenarios: draft.scenarios,
|
|
217
218
|
...(draft.subject_invocations ? { subject_invocations: draft.subject_invocations } : {}),
|
package/dist/run.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface RunOptions {
|
|
|
8
8
|
spec: Spec;
|
|
9
9
|
skillDir: string;
|
|
10
10
|
specPath: string;
|
|
11
|
+
/** Tests/results location; defaults to <skillDir>/tests for the single-root layout. */
|
|
12
|
+
testsDir?: string;
|
|
13
|
+
/** Record per-hash skills/specs provenance for a split-root run. */
|
|
14
|
+
recordSourceRoots?: boolean;
|
|
11
15
|
adapter: HarnessAdapter;
|
|
12
16
|
model: ModelRef;
|
|
13
17
|
modelToken: string;
|
package/dist/run.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { sourceHashes } from "./sources.js";
|
|
3
|
+
import { sourceHashes, sourceHashRoots } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
5
|
import { NONE_ARM, seedArmDefinitions } from "./arms.js";
|
|
6
6
|
import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
|
|
@@ -68,7 +68,7 @@ export async function runSkillModel(opts) {
|
|
|
68
68
|
`(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
|
|
69
69
|
}
|
|
70
70
|
const arm = opts.arm ?? NONE_ARM;
|
|
71
|
-
const runDir = runDirFor(skillDir, adapter.name, model, timestamp, arm.name);
|
|
71
|
+
const runDir = runDirFor(dirname(opts.testsDir ?? join(skillDir, "tests")), adapter.name, model, timestamp, arm.name);
|
|
72
72
|
mkdirSync(runDir, { recursive: true });
|
|
73
73
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
74
74
|
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
@@ -145,6 +145,7 @@ export async function runSkillModel(opts) {
|
|
|
145
145
|
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
146
146
|
});
|
|
147
147
|
const ctx = scoreContextFor({ mode, partial }, spec);
|
|
148
|
+
const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
|
|
148
149
|
const results = writeResults(runDir, {
|
|
149
150
|
skill: spec.skill,
|
|
150
151
|
harness: adapter.name,
|
|
@@ -158,7 +159,8 @@ export async function runSkillModel(opts) {
|
|
|
158
159
|
...(partial ? { partial: true } : {}),
|
|
159
160
|
// Only the scenarios this run actually measured: a --only run must not claim
|
|
160
161
|
// coverage of scenarios it skipped.
|
|
161
|
-
source_hashes:
|
|
162
|
+
source_hashes: hashes,
|
|
163
|
+
...(opts.recordSourceRoots ? { source_hash_roots: sourceHashRoots(hashes) } : {}),
|
|
162
164
|
scenarios: scenarioResults,
|
|
163
165
|
...(arm.name === NONE_ARM.name ? {} : {
|
|
164
166
|
arm: {
|
package/dist/sources.d.ts
CHANGED
|
@@ -151,6 +151,8 @@ export interface SourceContext {
|
|
|
151
151
|
* problem to report, not run's to crash on.
|
|
152
152
|
*/
|
|
153
153
|
export declare function sourceHashes(ctx: SourceContext): Record<string, string>;
|
|
154
|
+
/** Per-hash root provenance for split --skills/--specs runs. */
|
|
155
|
+
export declare function sourceHashRoots(hashes: Record<string, string>): Record<string, "skills" | "specs">;
|
|
154
156
|
/**
|
|
155
157
|
* The current hash for a recorded key, or null when the source is gone.
|
|
156
158
|
*
|
package/dist/sources.js
CHANGED
|
@@ -348,6 +348,10 @@ export function sourceHashes(ctx) {
|
|
|
348
348
|
}
|
|
349
349
|
return hashes;
|
|
350
350
|
}
|
|
351
|
+
/** Per-hash root provenance for split --skills/--specs runs. */
|
|
352
|
+
export function sourceHashRoots(hashes) {
|
|
353
|
+
return Object.fromEntries(Object.keys(hashes).map((key) => [key, key === SKILL_KEY || key === SKILL_PROMPT_KEY ? "skills" : "specs"]));
|
|
354
|
+
}
|
|
351
355
|
/**
|
|
352
356
|
* The current hash for a recorded key, or null when the source is gone.
|
|
353
357
|
*
|
package/package.json
CHANGED