@skill-harness/core 0.1.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,241 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, appendFileSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { modelSlug } from "./adapters/types.js";
5
+ import { score } from "./score.js";
6
+ /** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
7
+ export function effectiveThreshold(prevScenario, scenario) {
8
+ return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
9
+ }
10
+ /** Slugify an ISO timestamp into a filesystem-safe directory name. */
11
+ function timestampSlug(iso) {
12
+ return iso.replace(/[:.]/g, "-");
13
+ }
14
+ /** <skillDir>/tests/results/<harness>-<model-slug>/<timestamp-slug>/ */
15
+ export function runDirFor(skillDir, harness, model, timestamp) {
16
+ return join(skillDir, "tests", "results", `${harness}-${modelSlug(model)}`, timestampSlug(timestamp));
17
+ }
18
+ /** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
19
+ export function transcriptPath(runDir, scenarioId, mode, rep) {
20
+ const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
21
+ return join(runDir, `${base}.txt`);
22
+ }
23
+ export function reportPath(runDir) {
24
+ return join(runDir, "report.html");
25
+ }
26
+ export function resultsPath(runDir) {
27
+ return join(runDir, "results.yaml");
28
+ }
29
+ /** The verdict that counts: author override when present, else the judge's. */
30
+ export function effectiveVerdicts(scenarios) {
31
+ return scenarios.map((s) => ({
32
+ id: s.id,
33
+ verdict: s.override ?? s.judge_verdict,
34
+ suspect: s.suspect && s.override == null, // an override resolves the misfire
35
+ }));
36
+ }
37
+ /**
38
+ * The ONLY place effective_grade is computed. Every writer goes through here,
39
+ * so a persisted grade can never disagree with verdicts + overrides.
40
+ * ctx is null for unscored (red/force) runs.
41
+ */
42
+ export function finalizeResults(draft, ctx) {
43
+ let effective_grade;
44
+ if (ctx) {
45
+ const s = score(effectiveVerdicts(draft.scenarios), { shipBar: ctx.shipBar, critical: ctx.critical });
46
+ effective_grade = { passed: s.passed, total: s.total, pct: s.pct, letter: s.letter, ship: s.ship, note: s.note };
47
+ }
48
+ else {
49
+ effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: `mode=${draft.mode} (not scored)` };
50
+ }
51
+ return {
52
+ schema: 2,
53
+ skill: draft.skill,
54
+ harness: draft.harness,
55
+ model: draft.model,
56
+ judge: draft.judge,
57
+ timestamp: draft.timestamp,
58
+ label: draft.label,
59
+ mode: draft.mode,
60
+ effective_grade,
61
+ scenarios: draft.scenarios,
62
+ };
63
+ }
64
+ /** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
65
+ export function writeResults(runDir, draft, ctx) {
66
+ const results = finalizeResults(draft, ctx);
67
+ mkdirSync(runDir, { recursive: true });
68
+ writeFileSync(resultsPath(runDir), yaml.dump(results, { lineWidth: 100 }), "utf8");
69
+ return results;
70
+ }
71
+ const SUSPECT_PREFIX_RE = /^\[suspect misfire[^\]]*\]\s*/;
72
+ /** Read-only schema-1 → schema-2 migration. Never rewrites the file on disk. */
73
+ export function migrateResults(raw) {
74
+ if (raw == null || typeof raw !== "object") {
75
+ throw new Error("empty or invalid results.yaml");
76
+ }
77
+ const o = raw;
78
+ if (o.schema === 2)
79
+ return raw;
80
+ const v1 = raw;
81
+ const modeMatch = /^mode=(\w+)/.exec(v1.grade?.note ?? "");
82
+ return {
83
+ schema: 2,
84
+ skill: v1.skill,
85
+ harness: v1.harness,
86
+ model: v1.model,
87
+ judge: v1.judge,
88
+ timestamp: v1.timestamp,
89
+ label: null,
90
+ mode: modeMatch ? modeMatch[1] : "green",
91
+ // v1 grades may predate override-aware recompute; carried verbatim (read-only).
92
+ // Every v2 WRITE recomputes, so staleness cannot propagate.
93
+ effective_grade: v1.grade,
94
+ scenarios: (v1.scenarios ?? []).map((s) => {
95
+ const reason = s.judge_reason ?? "";
96
+ return {
97
+ ...s,
98
+ override: s.override ?? null,
99
+ note: s.note ?? "",
100
+ suspect: SUSPECT_PREFIX_RE.test(reason),
101
+ judge_reason: reason.replace(SUSPECT_PREFIX_RE, ""),
102
+ };
103
+ }),
104
+ };
105
+ }
106
+ /** Read results.yaml from a run dir, migrating schema-1 files in memory. */
107
+ export function readResults(runDir) {
108
+ const text = readFileSync(resultsPath(runDir), "utf8");
109
+ return migrateResults(yaml.load(text));
110
+ }
111
+ /** Pure: return a copy with override + note applied to one scenario. */
112
+ export function applyOverride(results, scenarioId, override, note) {
113
+ if (override !== null && note.trim() === "") {
114
+ throw new Error(`override for \`${scenarioId}\` requires a note — say why the judge was wrong`);
115
+ }
116
+ let found = false;
117
+ const scenarios = results.scenarios.map((s) => {
118
+ if (s.id !== scenarioId)
119
+ return s;
120
+ found = true;
121
+ return { ...s, override, note };
122
+ });
123
+ if (!found) {
124
+ throw new Error(`no scenario \`${scenarioId}\` in results`);
125
+ }
126
+ return { ...results, scenarios };
127
+ }
128
+ const GITIGNORE_BODY = `# skill-harness: commit verdicts (results.yaml), ignore generated artifacts.
129
+ *.txt
130
+ *.jsonl
131
+ report.html
132
+ !results.yaml
133
+ `;
134
+ /**
135
+ * Manage results/.gitignore: transcripts + reports ignored, results.yaml tracked.
136
+ * Rewrites a stale managed body (so new ignore rules roll out) while keeping any
137
+ * `!…` preservation lines added by preserveTranscript.
138
+ */
139
+ export function ensureResultsGitignore(resultsRoot) {
140
+ mkdirSync(resultsRoot, { recursive: true });
141
+ const giPath = join(resultsRoot, ".gitignore");
142
+ const existing = existsSync(giPath) ? readFileSync(giPath, "utf8") : "";
143
+ if (existing.startsWith(GITIGNORE_BODY))
144
+ return;
145
+ const preserved = existing
146
+ .split("\n")
147
+ .filter((l) => l.startsWith("!") && l.trim() !== "!results.yaml");
148
+ writeFileSync(giPath, GITIGNORE_BODY + preserved.map((l) => l + "\n").join(""), "utf8");
149
+ }
150
+ // Matches both transcript (`.rep<k>.txt`) and judge-raw (`.rep<k>.judge.txt`) rep suffixes.
151
+ const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.)?txt$/;
152
+ /** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
153
+ export function repIndexOf(filename) {
154
+ const m = REP_SUFFIX_RE.exec(filename);
155
+ return m ? Number(m[1]) : null;
156
+ }
157
+ /** Sort transcript-like filenames: plain (no rep) first, then by numeric rep index. */
158
+ function sortByRep(files) {
159
+ return files.sort((a, b) => {
160
+ const ra = repIndexOf(a);
161
+ const rb = repIndexOf(b);
162
+ if (ra === null && rb === null)
163
+ return a.localeCompare(b);
164
+ if (ra === null)
165
+ return -1;
166
+ if (rb === null)
167
+ return 1;
168
+ return ra - rb;
169
+ });
170
+ }
171
+ /**
172
+ * ALL transcript files for a scenario in a run dir, sorted deterministically:
173
+ * a plain `<id>.<mode>.txt` first (if present), then rep-suffixed files
174
+ * (`<id>.<mode>.rep<k>.txt`) in numeric rep order. Empty if the run dir or
175
+ * scenario has no transcripts.
176
+ *
177
+ * With `mode` given, only that mode's transcripts match (`<id>.<mode>.txt` /
178
+ * `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
179
+ * false positives from a red/force transcript of the same scenario. Omitted,
180
+ * behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
181
+ * scenario's judge-raw artifacts (`<id>.*.judge.txt` — see judgeRawPath).
182
+ */
183
+ export function findTranscriptFiles(runDir, scenarioId, mode) {
184
+ if (!existsSync(runDir))
185
+ return [];
186
+ const escapedId = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ const matcher = mode !== undefined
188
+ ? new RegExp(`^${escapedId}\\.${mode}(\\.rep\\d+)?\\.txt$`)
189
+ : null;
190
+ const files = readdirSync(runDir).filter((f) => matcher ? matcher.test(f) : f.startsWith(`${scenarioId}.`) && f.endsWith(".txt") && !f.endsWith(".judge.txt"));
191
+ return sortByRep(files);
192
+ }
193
+ /** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
194
+ export function judgeRawPath(runDir, scenarioId, mode, rep) {
195
+ const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
196
+ return join(runDir, `${base}.judge.txt`);
197
+ }
198
+ /** A scenario's raw judge-output files, sorted (plain first, then numeric rep). Mode-scoped when given. */
199
+ export function findJudgeRawFiles(runDir, scenarioId, mode) {
200
+ if (!existsSync(runDir))
201
+ return [];
202
+ const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
+ const re = mode === undefined
204
+ ? new RegExp(`^${esc}\\..*\\.judge\\.txt$`)
205
+ : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\.txt$`);
206
+ return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
207
+ }
208
+ /** A single representative transcript file for a scenario in a run dir. Null if none. */
209
+ export function findTranscriptFile(runDir, scenarioId) {
210
+ return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
211
+ }
212
+ /**
213
+ * Un-gitignore ALL of a scenario's transcript AND judge-raw artifact files
214
+ * (audit trail for an override — a --reps run has one transcript (and one
215
+ * judge-raw file) per rep, and every rep that drove the verdict must survive
216
+ * a commit, not just an arbitrary one).
217
+ * Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
218
+ * `.judge.txt`) to results/.gitignore for each, once. The path uses POSIX
219
+ * separators so the negation matches on Windows too (git ignore patterns are
220
+ * always forward-slashed).
221
+ */
222
+ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
223
+ const files = [...findTranscriptFiles(runDir, scenarioId), ...findJudgeRawFiles(runDir, scenarioId)];
224
+ if (files.length === 0)
225
+ return;
226
+ ensureResultsGitignore(resultsRoot);
227
+ const giPath = join(resultsRoot, ".gitignore");
228
+ const existingLines = readFileSync(giPath, "utf8").split("\n");
229
+ const newLines = [];
230
+ for (const file of files) {
231
+ const rel = relative(resultsRoot, join(runDir, file)).split(sep).join("/");
232
+ const line = `!${rel}`;
233
+ if (!existingLines.includes(line) && !newLines.includes(line)) {
234
+ newLines.push(line);
235
+ }
236
+ }
237
+ if (newLines.length > 0) {
238
+ appendFileSync(giPath, newLines.map((l) => l + "\n").join(""), "utf8");
239
+ }
240
+ }
241
+ //# sourceMappingURL=results.js.map
package/dist/run.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { Spec } from "./spec.js";
2
+ import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
+ import { type ResultsFile } from "./results.js";
4
+ export interface RunOptions {
5
+ spec: Spec;
6
+ skillDir: string;
7
+ specPath: string;
8
+ adapter: HarnessAdapter;
9
+ model: ModelRef;
10
+ modelToken: string;
11
+ judge: ModelRef;
12
+ mode: RunMode;
13
+ timestamp: string;
14
+ label?: string | null;
15
+ onProgress?: (msg: string) => void;
16
+ now?: () => string;
17
+ concurrency?: number;
18
+ reps?: number;
19
+ passThreshold?: number;
20
+ }
21
+ export interface RunSummary {
22
+ runDir: string;
23
+ results: ResultsFile;
24
+ }
25
+ /** Run one skill against one model: run scenarios, grade, score, persist. */
26
+ export declare function runSkillModel(opts: RunOptions): Promise<RunSummary>;
27
+ /** A compact terminal scorecard for one run. */
28
+ export declare function formatScorecard(summary: RunSummary): string;
package/dist/run.js ADDED
@@ -0,0 +1,148 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { judgeResemblesSubject } from "./grade.js";
4
+ import { runDirFor, transcriptPath, writeResults, ensureResultsGitignore, } from "./results.js";
5
+ import { appendJournal } from "./journal.js";
6
+ import { runSeeded } from "./seeded.js";
7
+ import { createWorkspace } from "./workspace.js";
8
+ import { runPool } from "./scheduler.js";
9
+ import { outcomesToResult } from "./reps.js";
10
+ import { judgeOneRep } from "./regrade.js";
11
+ /** Run one skill against one model: run scenarios, grade, score, persist. */
12
+ export async function runSkillModel(opts) {
13
+ const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
14
+ const log = opts.onProgress ?? (() => { });
15
+ const now = opts.now ?? (() => new Date().toISOString());
16
+ if (judgeResemblesSubject(judge, model)) {
17
+ log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
18
+ `(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
19
+ }
20
+ const runDir = runDirFor(skillDir, adapter.name, model, timestamp);
21
+ mkdirSync(runDir, { recursive: true });
22
+ ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
23
+ appendJournal(runDir, {
24
+ event: "run-started", ts: now(),
25
+ skill: spec.skill, harness: adapter.name, model: opts.modelToken,
26
+ judge: { provider: judge.provider, model: judge.model },
27
+ mode, label: opts.label ?? null,
28
+ });
29
+ // scenario × rep tasks; runPool preserves input order so we can slice per scenario.
30
+ const repCounts = spec.scenarios.map((s) => s.reps ?? opts.reps ?? 1);
31
+ const owners = [];
32
+ const tasks = [];
33
+ spec.scenarios.forEach((scenario, si) => {
34
+ for (let k = 0; k < repCounts[si]; k++) {
35
+ const rep = k;
36
+ const total = repCounts[si];
37
+ owners.push(si);
38
+ tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log }));
39
+ }
40
+ });
41
+ const flat = await runPool(tasks, opts.concurrency ?? 1);
42
+ const grouped = spec.scenarios.map(() => []);
43
+ flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
44
+ const scenarioResults = spec.scenarios.map((scenario, si) => {
45
+ const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
46
+ return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
47
+ });
48
+ const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
49
+ const results = writeResults(runDir, {
50
+ skill: spec.skill,
51
+ harness: adapter.name,
52
+ model: opts.modelToken,
53
+ judge: { provider: judge.provider, model: judge.model },
54
+ timestamp,
55
+ label: opts.label ?? null,
56
+ mode,
57
+ scenarios: scenarioResults,
58
+ }, ctx);
59
+ if (ctx) {
60
+ const g = results.effective_grade;
61
+ appendJournal(runDir, { event: "score", ts: now(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
62
+ }
63
+ return { runDir, results };
64
+ }
65
+ /** Run ONE rep of a scenario in its own isolated workspace. */
66
+ async function runRep(scenario, rep, repCount, ctx) {
67
+ const { spec, judge, mode, runDir, now, log } = ctx;
68
+ const repField = repCount > 1 ? { rep } : {};
69
+ if (rep === 0) {
70
+ log(` ${scenario.id} (${scenario.title})${repCount > 1 ? ` ×${repCount}` : ""} …`);
71
+ appendJournal(runDir, { event: "scenario-started", ts: now(), id: scenario.id, title: scenario.title });
72
+ }
73
+ let ws = null;
74
+ let transcript = "";
75
+ let gatePrefix = null;
76
+ try {
77
+ try {
78
+ ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath) });
79
+ }
80
+ catch (e) {
81
+ // A setup failure (e.g. missing fixture) is an objective FAIL, not an infra abort.
82
+ gatePrefix = e instanceof Error ? e.message : String(e);
83
+ transcript = `[workspace setup failed] ${gatePrefix}`;
84
+ }
85
+ if (ws) {
86
+ if (scenario.mode === "seeded") {
87
+ const r = await runSeeded(scenario, {
88
+ skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
89
+ });
90
+ transcript = r.transcript;
91
+ gatePrefix = r.gateFailure;
92
+ }
93
+ else {
94
+ transcript = await ctx.adapter.run({
95
+ skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
96
+ });
97
+ }
98
+ }
99
+ writeFileSync(transcriptPath(runDir, scenario.id, mode, repCount > 1 ? rep : undefined), transcript, "utf8");
100
+ if (scenario.mode === "seeded") {
101
+ appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
102
+ }
103
+ let verdict;
104
+ let reason;
105
+ let suspect = false;
106
+ if (gatePrefix) {
107
+ verdict = "FAIL";
108
+ reason = gatePrefix;
109
+ // gate failures don't invoke the judge, but still record a judge-verdict event (as before)
110
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
111
+ }
112
+ else {
113
+ const o = await judgeOneRep({
114
+ runDir, spec, scenario, transcript, adapter: ctx.adapter, judge,
115
+ specDir: dirname(ctx.specPath), mode, rep: repCount > 1 ? rep : undefined, now,
116
+ });
117
+ verdict = o.verdict;
118
+ reason = o.reason;
119
+ suspect = o.suspect; // judgeOneRep already journaled (verdict + misfire)
120
+ }
121
+ log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
122
+ return { verdict, reason, suspect };
123
+ }
124
+ finally {
125
+ ws?.cleanup();
126
+ }
127
+ }
128
+ /** A compact terminal scorecard for one run. */
129
+ export function formatScorecard(summary) {
130
+ const { results } = summary;
131
+ const g = results.effective_grade;
132
+ const lines = [];
133
+ lines.push(`── ${results.skill} · ${results.harness} · ${results.model} ──`);
134
+ for (const s of results.scenarios) {
135
+ const v = s.override ?? s.judge_verdict;
136
+ const mark = v === "PASS" ? "✓" : v === "FAIL" ? "✗" : "?";
137
+ const ov = s.override ? " (override)" : "";
138
+ const susp = s.suspect ? " ⚠suspect" : "";
139
+ const misfired = s.clean !== undefined && s.reps !== undefined && s.clean < s.reps ? ` · ${s.reps - s.clean} misfired` : "";
140
+ const repInfo = s.reps ? ` [${s.passes}/${s.clean}${misfired}${s.flakiness ? ` flaky ${s.flakiness.toFixed(2)}` : ""}]` : "";
141
+ lines.push(` ${mark} ${s.id}${ov}${susp} ${s.judge_reason}${repInfo}`);
142
+ }
143
+ const ship = g.ship ? "SHIP" : "NOT READY";
144
+ const note = g.note ? ` (${g.note})` : "";
145
+ lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
146
+ return lines.join("\n");
147
+ }
148
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Run `tasks` with at most `concurrency` thunks in flight at once, returning
3
+ * their results in input order (not completion order). `concurrency <= 1` runs
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.
10
+ */
11
+ export declare function runPool<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Run `tasks` with at most `concurrency` thunks in flight at once, returning
3
+ * their results in input order (not completion order). `concurrency <= 1` runs
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.
10
+ */
11
+ export async function runPool(tasks, concurrency) {
12
+ const limit = Math.max(1, Math.floor(concurrency));
13
+ const results = new Array(tasks.length);
14
+ let next = 0;
15
+ async function worker() {
16
+ while (true) {
17
+ const i = next++;
18
+ if (i >= tasks.length)
19
+ return;
20
+ results[i] = await tasks[i]();
21
+ }
22
+ }
23
+ const workerCount = Math.min(limit, tasks.length);
24
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
25
+ return results;
26
+ }
27
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1,33 @@
1
+ import type { ShipBar } from "./spec.js";
2
+ export type Verdict = "PASS" | "FAIL" | "ERROR";
3
+ export interface ScenarioVerdict {
4
+ id: string;
5
+ verdict: Verdict;
6
+ suspect?: boolean;
7
+ }
8
+ export interface ScoreInput {
9
+ shipBar: ShipBar;
10
+ critical: string[];
11
+ }
12
+ export interface ScoreResult {
13
+ passed: number;
14
+ total: number;
15
+ pct: number;
16
+ letter: string;
17
+ ship: boolean;
18
+ criticalFails: number;
19
+ bSeriesFails: number;
20
+ suspectCount: number;
21
+ note: string;
22
+ }
23
+ export declare function letterFor(pct: number): string;
24
+ /**
25
+ * Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
26
+ * on verdict PASS; FAIL and ERROR both count against it. A `suspect` verdict (an
27
+ * unresolved judge misfire) is excluded from both `passed` and `total` — it is
28
+ * untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
29
+ * until an author override resolves it. SHIP otherwise requires: enough total
30
+ * scenarios, >= min_pass passes, zero critical fails (when no_critical_fail),
31
+ * and zero B-series (id starting with "B") fails — hold-the-line is the discipline axis.
32
+ */
33
+ export declare function score(verdicts: ScenarioVerdict[], input: ScoreInput): ScoreResult;
package/dist/score.js ADDED
@@ -0,0 +1,62 @@
1
+ export function letterFor(pct) {
2
+ if (pct >= 90)
3
+ return "A";
4
+ if (pct >= 80)
5
+ return "B";
6
+ if (pct >= 70)
7
+ return "C";
8
+ if (pct >= 60)
9
+ return "D";
10
+ return "F";
11
+ }
12
+ /**
13
+ * Score a set of green-mode verdicts against the ship bar. A scenario PASSes only
14
+ * on verdict PASS; FAIL and ERROR both count against it. A `suspect` verdict (an
15
+ * unresolved judge misfire) is excluded from both `passed` and `total` — it is
16
+ * untrustworthy, neither a pass nor a fail — and any suspect count blocks SHIP
17
+ * until an author override resolves it. SHIP otherwise requires: enough total
18
+ * scenarios, >= min_pass passes, zero critical fails (when no_critical_fail),
19
+ * and zero B-series (id starting with "B") fails — hold-the-line is the discipline axis.
20
+ */
21
+ export function score(verdicts, input) {
22
+ const { shipBar, critical } = input;
23
+ let passed = 0;
24
+ let total = 0;
25
+ let criticalFails = 0;
26
+ let bSeriesFails = 0;
27
+ let suspectCount = 0;
28
+ for (const v of verdicts) {
29
+ if (v.suspect) {
30
+ suspectCount++;
31
+ continue; // untrustworthy: neither pass nor fail
32
+ }
33
+ total++;
34
+ if (v.verdict === "PASS") {
35
+ passed++;
36
+ continue;
37
+ }
38
+ if (critical.includes(v.id))
39
+ criticalFails++;
40
+ if (/^B/i.test(v.id))
41
+ bSeriesFails++;
42
+ }
43
+ const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
44
+ const letter = letterFor(pct);
45
+ const ship = total >= shipBar.total &&
46
+ passed >= shipBar.min_pass &&
47
+ (!shipBar.no_critical_fail || criticalFails === 0) &&
48
+ bSeriesFails === 0 &&
49
+ suspectCount === 0;
50
+ let note = "";
51
+ if (suspectCount > 0) {
52
+ note = `${suspectCount} suspect: re-judge/resolve`;
53
+ }
54
+ else if (criticalFails > 0) {
55
+ note = `gated: ${criticalFails} critical fail${criticalFails === 1 ? "" : "s"}`;
56
+ }
57
+ else if (bSeriesFails > 0) {
58
+ note = `gated: ${bSeriesFails} B-series fail${bSeriesFails === 1 ? "" : "s"}`;
59
+ }
60
+ return { passed, total, pct, letter, ship, criticalFails, bSeriesFails, suspectCount, note };
61
+ }
62
+ //# sourceMappingURL=score.js.map
@@ -0,0 +1,21 @@
1
+ import type { Scenario } from "./spec.js";
2
+ import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
+ interface SeededOpts {
4
+ skillDir: string;
5
+ adapter: HarnessAdapter;
6
+ model: ModelRef;
7
+ mode: RunMode;
8
+ cwd: string;
9
+ }
10
+ export interface SeededOutcome {
11
+ transcript: string;
12
+ gateFailure: string | null;
13
+ }
14
+ /**
15
+ * Run a seeded scenario inside a caller-prepared workspace: let the harness edit
16
+ * the repo, then evaluate objective gates (staged-diff contains + optional vitest
17
+ * pass). A failed gate short-circuits to an auto-FAIL. Workspace creation (fixture
18
+ * copy + git baseline) and teardown are the caller's responsibility (run.ts).
19
+ */
20
+ export declare function runSeeded(scenario: Scenario, opts: SeededOpts): Promise<SeededOutcome>;
21
+ export {};
package/dist/seeded.js ADDED
@@ -0,0 +1,45 @@
1
+ import { exec } from "./util/exec.js";
2
+ const VITEST_TIMEOUT_MS = Number(process.env.SKILL_CHECK_VITEST_TIMEOUT_MS ?? 120_000);
3
+ /**
4
+ * Run a seeded scenario inside a caller-prepared workspace: let the harness edit
5
+ * the repo, then evaluate objective gates (staged-diff contains + optional vitest
6
+ * pass). A failed gate short-circuits to an auto-FAIL. Workspace creation (fixture
7
+ * copy + git baseline) and teardown are the caller's responsibility (run.ts).
8
+ */
9
+ export async function runSeeded(scenario, opts) {
10
+ const repo = opts.cwd;
11
+ const harnessOut = await opts.adapter.run({
12
+ skillDir: opts.skillDir,
13
+ model: opts.model,
14
+ mode: opts.mode,
15
+ turns: scenario.turns,
16
+ cwd: repo,
17
+ });
18
+ await git(repo, ["add", "-A"]);
19
+ const diff = (await git(repo, ["diff", "--cached"])).stdout;
20
+ const parts = [harnessOut, "", "=== SEEDED GATES ==="];
21
+ let gateFailure = null;
22
+ const wantDiff = scenario.assert?.diff_contains ?? [];
23
+ for (const needle of wantDiff) {
24
+ const ok = diff.includes(needle);
25
+ parts.push(` diff_contains ${JSON.stringify(needle)}: ${ok ? "OK" : "MISSING"}`);
26
+ if (!ok && !gateFailure)
27
+ gateFailure = `staged diff missing ${JSON.stringify(needle)}`;
28
+ }
29
+ if (scenario.assert?.vitest) {
30
+ const v = await exec("npx", ["vitest", "run"], { cwd: repo, timeoutMs: VITEST_TIMEOUT_MS });
31
+ const passed = v.code === 0;
32
+ parts.push(` vitest run: ${passed ? "PASS" : `FAIL (exit ${v.code})`}`);
33
+ parts.push(indent(v.stdout.trim() || v.stderr.trim()));
34
+ if (!passed && !gateFailure)
35
+ gateFailure = `vitest failed (exit ${v.code})`;
36
+ }
37
+ return { transcript: parts.join("\n"), gateFailure };
38
+ }
39
+ function git(cwd, args) {
40
+ return exec("git", args, { cwd, timeoutMs: 30_000 });
41
+ }
42
+ function indent(s) {
43
+ return s.split("\n").map((l) => ` ${l}`).join("\n");
44
+ }
45
+ //# sourceMappingURL=seeded.js.map
package/dist/spec.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { WorkspaceKind } from "./workspace.js";
2
+ export type ScenarioMode = "inline" | "seeded";
3
+ export interface SeededAssert {
4
+ vitest?: boolean;
5
+ diff_contains?: string[];
6
+ }
7
+ export interface Scenario {
8
+ id: string;
9
+ title: string;
10
+ critical: boolean;
11
+ mode: ScenarioMode;
12
+ turns: string[];
13
+ checklist: string[];
14
+ fixture?: string;
15
+ assert?: SeededAssert;
16
+ workspace: WorkspaceKind;
17
+ reps?: number;
18
+ passThreshold?: number;
19
+ }
20
+ export interface ShipBar {
21
+ total: number;
22
+ min_pass: number;
23
+ no_critical_fail: boolean;
24
+ }
25
+ export interface Spec {
26
+ skill: string;
27
+ judge_persona: string;
28
+ ship_bar: ShipBar;
29
+ critical: string[];
30
+ scenarios: Scenario[];
31
+ }
32
+ /** Thrown on any validation failure. Message always carries the spec file path. */
33
+ export declare class SpecError extends Error {
34
+ constructor(message: string, file: string);
35
+ }
36
+ /** Parse + validate a specification.yaml from its raw text. `file` is used in error messages. */
37
+ export declare function parseSpec(text: string, file: string): Spec;
38
+ /** Load + validate a specification.yaml from disk. */
39
+ export declare function loadSpec(file: string): Spec;