@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mojo Manyana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,29 @@
1
+ export type RunMode = "red" | "green" | "force";
2
+ /** A provider+model pair, e.g. { provider: "fireworks", model: "accounts/.../deepseek-v4-pro" }. */
3
+ export interface ModelRef {
4
+ provider: string;
5
+ model: string;
6
+ }
7
+ /** Parse a `provider:model` token (model may contain further colons/slashes). */
8
+ export declare function parseModelRef(token: string): ModelRef;
9
+ /** Stable, filesystem-safe slug for a model ref (used in run-dir names). */
10
+ export declare function modelSlug(ref: ModelRef): string;
11
+ export interface RunReq {
12
+ skillDir: string;
13
+ model: ModelRef;
14
+ mode: RunMode;
15
+ turns: string[];
16
+ cwd: string;
17
+ }
18
+ /** A judge request: single prompt, no skills, no session. */
19
+ export interface JudgeReq {
20
+ model: ModelRef;
21
+ prompt: string;
22
+ cwd: string;
23
+ }
24
+ export interface HarnessAdapter {
25
+ name: string;
26
+ available(): Promise<boolean>;
27
+ run(req: RunReq): Promise<string>;
28
+ judge(req: JudgeReq): Promise<string>;
29
+ }
@@ -0,0 +1,18 @@
1
+ /** Parse a `provider:model` token (model may contain further colons/slashes). */
2
+ export function parseModelRef(token) {
3
+ const i = token.indexOf(":");
4
+ if (i < 0) {
5
+ throw new Error(`model must be \`provider:model\` (got \`${token}\`)`);
6
+ }
7
+ const provider = token.slice(0, i).trim();
8
+ const model = token.slice(i + 1).trim();
9
+ if (!provider || !model) {
10
+ throw new Error(`model must be \`provider:model\` (got \`${token}\`)`);
11
+ }
12
+ return { provider, model };
13
+ }
14
+ /** Stable, filesystem-safe slug for a model ref (used in run-dir names). */
15
+ export function modelSlug(ref) {
16
+ return `${ref.provider}-${ref.model}`.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
17
+ }
18
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,14 @@
1
+ export interface DiscoveredSkill {
2
+ name: string;
3
+ dir: string;
4
+ hasSpec: boolean;
5
+ specPath: string;
6
+ }
7
+ /**
8
+ * Scan a skills root. A "skill" is any immediate subdirectory containing a
9
+ * SKILL.md. It is testable iff `<skill>/tests/specification.yaml` exists.
10
+ * Returns skills sorted by name (testable or not).
11
+ */
12
+ export declare function discover(root: string): DiscoveredSkill[];
13
+ /** Resolve a single skill by name; throws a helpful error if absent or specless. */
14
+ export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
@@ -0,0 +1,35 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Scan a skills root. A "skill" is any immediate subdirectory containing a
5
+ * SKILL.md. It is testable iff `<skill>/tests/specification.yaml` exists.
6
+ * Returns skills sorted by name (testable or not).
7
+ */
8
+ export function discover(root) {
9
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
10
+ throw new Error(`skills root is not a directory: ${root}`);
11
+ }
12
+ const skills = [];
13
+ for (const name of readdirSync(root)) {
14
+ if (name.startsWith("."))
15
+ continue;
16
+ const dir = join(root, name);
17
+ if (!statSync(dir).isDirectory())
18
+ continue;
19
+ if (!existsSync(join(dir, "SKILL.md")))
20
+ continue;
21
+ const specPath = join(dir, "tests", "specification.yaml");
22
+ skills.push({ name, dir, hasSpec: existsSync(specPath), specPath });
23
+ }
24
+ skills.sort((a, b) => a.name.localeCompare(b.name));
25
+ return skills;
26
+ }
27
+ /** Resolve a single skill by name; throws a helpful error if absent or specless. */
28
+ export function resolveSkill(root, name) {
29
+ const skill = discover(root).find((s) => s.name === name);
30
+ if (!skill) {
31
+ throw new Error(`no skill \`${name}\` under ${root}`);
32
+ }
33
+ return skill;
34
+ }
35
+ //# sourceMappingURL=discover.js.map
@@ -0,0 +1,44 @@
1
+ import type { Scenario } from "./spec.js";
2
+ import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
3
+ import type { Verdict } from "./score.js";
4
+ export interface JudgePromptInput {
5
+ skill: string;
6
+ persona: string;
7
+ scenario: Scenario;
8
+ transcript: string;
9
+ }
10
+ /** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
11
+ export declare function buildJudgePrompt(input: JudgePromptInput): string;
12
+ export interface ParsedVerdict {
13
+ verdict: Verdict;
14
+ reason: string;
15
+ }
16
+ /** Parse a judge's raw output into a verdict + reason. Unparseable → ERROR. */
17
+ export declare function parseVerdict(out: string): ParsedVerdict;
18
+ /**
19
+ * Judge-≠-subject de-confound guard. True when the judge resembles the model
20
+ * under test: same provider AND one model id contains the other (same family).
21
+ * opus-judging-opus inflated scores before — never let the judge sit in the model set.
22
+ */
23
+ export declare function judgeResemblesSubject(judge: ModelRef, subject: ModelRef): boolean;
24
+ export interface GradeResult extends ParsedVerdict {
25
+ raw: string;
26
+ /** Judge misfire: the overall verdict disagrees with AND(per-item grades). Recorded, never auto-passed; blocks SHIP until re-judged or overridden. */
27
+ suspect: boolean;
28
+ }
29
+ /**
30
+ * Judge-misfire detector: parse the judge's per-checklist-item grades and assert
31
+ * the overall verdict equals AND(items). A mismatch in EITHER direction — verdict
32
+ * PASS with a FAILed item (false-pass), or verdict FAIL with every item PASSing
33
+ * (the observed ~2% false-fail class) — is a misfire. Fail-open: if no item lines
34
+ * parse, or the verdict is ERROR, return false (never block a run on a parse miss).
35
+ */
36
+ export declare function detectMisfire(raw: string, verdict: Verdict): boolean;
37
+ /** Drive the judge for one transcript and parse the result. */
38
+ export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string): Promise<GradeResult>;
39
+ /**
40
+ * Grade a transcript in a fresh, isolated, throwaway workspace — never the
41
+ * subject's scenario dir — so the judge can't ingest repo context the subject
42
+ * left behind (matters for CLI judges that read cwd, e.g. claude-code).
43
+ */
44
+ export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string): Promise<GradeResult>;
package/dist/grade.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createWorkspace } from "./workspace.js";
2
+ /** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
3
+ export function buildJudgePrompt(input) {
4
+ const { skill, persona, scenario, transcript } = input;
5
+ const numbered = scenario.checklist.map((c, i) => `${i + 1}. ${c}`).join("\n");
6
+ return `You are grading ONE response from an AI assistant using a "${skill}" skill — ${persona} Judge it ONLY against the checklist below — do not add requirements beyond it.
7
+
8
+ CHECKLIST (every numbered item must hold for a PASS):
9
+ ${numbered}
10
+
11
+ TRANSCRIPT (the assistant is the model under test):
12
+ ${transcript}
13
+
14
+ Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Then output exactly these two lines:
15
+ VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
16
+ REASON: <15 words or fewer>`;
17
+ }
18
+ const VERDICT_RE = /VERDICT\**\s*:?\s*\**\s*(PASS|FAIL)/i;
19
+ const REASON_RE = /REASON\**\s*:?\s*\**\s*(.*)$/im;
20
+ /** Parse a judge's raw output into a verdict + reason. Unparseable → ERROR. */
21
+ export function parseVerdict(out) {
22
+ const vm = out.match(VERDICT_RE);
23
+ if (!vm) {
24
+ return { verdict: "ERROR", reason: "judge produced no parseable verdict" };
25
+ }
26
+ const verdict = vm[1].toUpperCase();
27
+ const rm = out.match(REASON_RE);
28
+ const reason = rm ? rm[1].trim() : "";
29
+ return { verdict, reason };
30
+ }
31
+ /**
32
+ * Judge-≠-subject de-confound guard. True when the judge resembles the model
33
+ * under test: same provider AND one model id contains the other (same family).
34
+ * opus-judging-opus inflated scores before — never let the judge sit in the model set.
35
+ */
36
+ export function judgeResemblesSubject(judge, subject) {
37
+ if (judge.provider !== subject.provider)
38
+ return false;
39
+ const a = judge.model;
40
+ const b = subject.model;
41
+ return a === b || a.includes(b) || b.includes(a);
42
+ }
43
+ const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
44
+ /**
45
+ * Judge-misfire detector: parse the judge's per-checklist-item grades and assert
46
+ * the overall verdict equals AND(items). A mismatch in EITHER direction — verdict
47
+ * PASS with a FAILed item (false-pass), or verdict FAIL with every item PASSing
48
+ * (the observed ~2% false-fail class) — is a misfire. Fail-open: if no item lines
49
+ * parse, or the verdict is ERROR, return false (never block a run on a parse miss).
50
+ */
51
+ export function detectMisfire(raw, verdict) {
52
+ if (verdict === "ERROR")
53
+ return false;
54
+ const items = [...raw.matchAll(ITEM_RE)].map((m) => m[1].toUpperCase() === "PASS");
55
+ if (items.length === 0)
56
+ return false; // fail-open
57
+ const andItems = items.every((ok) => ok);
58
+ const verdictBool = verdict === "PASS";
59
+ return verdictBool !== andItems;
60
+ }
61
+ /** Drive the judge for one transcript and parse the result. */
62
+ export async function gradeTranscript(adapter, judge, prompt, cwd) {
63
+ const raw = await adapter.judge({ model: judge, prompt, cwd });
64
+ const parsed = parseVerdict(raw);
65
+ // On a parse failure, surface what the judge actually emitted (e.g. a provider
66
+ // error) rather than a generic message — otherwise the cause is invisible.
67
+ if (parsed.verdict === "ERROR") {
68
+ const snippet = raw.trim().replace(/\s+/g, " ").slice(0, 160);
69
+ if (snippet)
70
+ parsed.reason = `judge unparseable: ${snippet}`;
71
+ }
72
+ const suspect = detectMisfire(raw, parsed.verdict);
73
+ return { ...parsed, raw, suspect };
74
+ }
75
+ /**
76
+ * Grade a transcript in a fresh, isolated, throwaway workspace — never the
77
+ * subject's scenario dir — so the judge can't ingest repo context the subject
78
+ * left behind (matters for CLI judges that read cwd, e.g. claude-code).
79
+ */
80
+ export async function judgeInWorkspace(adapter, judge, prompt, specDir) {
81
+ const ws = createWorkspace("none", { specDir });
82
+ try {
83
+ return await gradeTranscript(adapter, judge, prompt, ws.cwd);
84
+ }
85
+ finally {
86
+ ws.cleanup();
87
+ }
88
+ }
89
+ //# sourceMappingURL=grade.js.map
@@ -0,0 +1,17 @@
1
+ export * from "./spec.js";
2
+ export * from "./discover.js";
3
+ export * from "./run.js";
4
+ export * from "./grade.js";
5
+ export * from "./score.js";
6
+ export * from "./results.js";
7
+ export * from "./journal.js";
8
+ export * from "./scheduler.js";
9
+ export * from "./reps.js";
10
+ export * from "./regrade.js";
11
+ export * from "./workspace.js";
12
+ export * from "./seeded.js";
13
+ export * from "./report.js";
14
+ export * from "./trends.js";
15
+ export * from "./lint.js";
16
+ export * from "./adapters/types.js";
17
+ export * from "./util/exec.js";
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export * from "./spec.js";
2
+ export * from "./discover.js";
3
+ export * from "./run.js";
4
+ export * from "./grade.js";
5
+ export * from "./score.js";
6
+ export * from "./results.js";
7
+ export * from "./journal.js";
8
+ export * from "./scheduler.js";
9
+ export * from "./reps.js";
10
+ export * from "./regrade.js";
11
+ export * from "./workspace.js";
12
+ export * from "./seeded.js";
13
+ export * from "./report.js";
14
+ export * from "./trends.js";
15
+ export * from "./lint.js";
16
+ export * from "./adapters/types.js";
17
+ export * from "./util/exec.js";
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,76 @@
1
+ import type { Verdict } from "./score.js";
2
+ /**
3
+ * Machine-facing event stream for one run: one JSON object per line in
4
+ * <runDir>/journal.jsonl. UI, trends, and debugging read ONLY this (never
5
+ * scrape terminal output). `turn` events arrive with per-turn streaming (M4+).
6
+ *
7
+ * A re-grade (`skill-harness grade`) appends a second wave of judge-verdict
8
+ * events and a new score event to the same journal — for a single-rep run
9
+ * (or a re-grade of one), consumers take the LAST score event and the LAST
10
+ * judge-verdict per scenario id.
11
+ *
12
+ * That "last per id" rule does NOT apply to a `--reps N>1` run: it emits N
13
+ * `judge-verdict`/`misfire-flag` events per scenario id, one per rep
14
+ * (identified by the `rep` field), and no aggregate event. These per-rep
15
+ * events are not an aggregate — results.yaml holds the authoritative
16
+ * aggregated verdict/pass-rate for the scenario; taking the last per id
17
+ * would yield an arbitrary rep's verdict, not the aggregated one.
18
+ */
19
+ export type JournalEvent = {
20
+ event: "run-started";
21
+ ts: string;
22
+ skill: string;
23
+ harness: string;
24
+ model: string;
25
+ judge: {
26
+ provider: string;
27
+ model: string;
28
+ };
29
+ mode: string;
30
+ label: string | null;
31
+ } | {
32
+ event: "scenario-started";
33
+ ts: string;
34
+ id: string;
35
+ title: string;
36
+ } | {
37
+ event: "gate-result";
38
+ ts: string;
39
+ id: string;
40
+ ok: boolean;
41
+ detail: string;
42
+ rep?: number;
43
+ } | {
44
+ event: "judge-verdict";
45
+ ts: string;
46
+ id: string;
47
+ verdict: Verdict;
48
+ reason: string;
49
+ suspect: boolean;
50
+ rep?: number;
51
+ } | {
52
+ event: "misfire-flag";
53
+ ts: string;
54
+ id: string;
55
+ reason: string;
56
+ rep?: number;
57
+ } | {
58
+ event: "score";
59
+ ts: string;
60
+ passed: number;
61
+ total: number;
62
+ pct: number;
63
+ letter: string;
64
+ ship: boolean;
65
+ note: string;
66
+ } | {
67
+ event: "override";
68
+ ts: string;
69
+ id: string;
70
+ override: Verdict | null;
71
+ note: string;
72
+ };
73
+ export declare function journalPath(runDir: string): string;
74
+ export declare function appendJournal(runDir: string, e: JournalEvent): void;
75
+ /** Read all events; missing file → []. Corrupt lines are skipped, never fatal. */
76
+ export declare function readJournal(runDir: string): JournalEvent[];
@@ -0,0 +1,32 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function journalPath(runDir) {
4
+ return join(runDir, "journal.jsonl");
5
+ }
6
+ export function appendJournal(runDir, e) {
7
+ mkdirSync(runDir, { recursive: true });
8
+ appendFileSync(journalPath(runDir), JSON.stringify(e) + "\n", "utf8");
9
+ }
10
+ /** Read all events; missing file → []. Corrupt lines are skipped, never fatal. */
11
+ export function readJournal(runDir) {
12
+ const p = journalPath(runDir);
13
+ if (!existsSync(p))
14
+ return [];
15
+ const events = [];
16
+ for (const line of readFileSync(p, "utf8").split("\n")) {
17
+ if (!line.trim())
18
+ continue;
19
+ try {
20
+ const ev = JSON.parse(line);
21
+ if (ev && typeof ev === "object" && typeof ev.event === "string") {
22
+ events.push(ev);
23
+ }
24
+ // else: valid JSON but not a journal event — skip
25
+ }
26
+ catch {
27
+ /* tolerate a torn/corrupt line */
28
+ }
29
+ }
30
+ return events;
31
+ }
32
+ //# sourceMappingURL=journal.js.map
package/dist/lint.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "lint-error";
2
+ export interface LintFinding {
3
+ readonly skill: string;
4
+ readonly scenario?: string;
5
+ readonly code: LintCode;
6
+ readonly message: string;
7
+ }
8
+ /**
9
+ * Validate one skill's spec + fixtures statically (and results-consistency when
10
+ * committed results exist — see the consistency block). Never throws: a bad spec
11
+ * becomes a single `code:"spec"` finding. Returns ALL findings so the CLI can
12
+ * report every problem across every skill.
13
+ */
14
+ export declare function lintSkill(skillDir: string): LintFinding[];
package/dist/lint.js ADDED
@@ -0,0 +1,137 @@
1
+ import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { loadSpec, SpecError } from "./spec.js";
5
+ import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
6
+ /** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
7
+ * symlink between the check and the stat is treated as "not a directory", not an error). */
8
+ function isDir(p) {
9
+ try {
10
+ return statSync(p).isDirectory();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Validate one skill's spec + fixtures statically (and results-consistency when
18
+ * committed results exist — see the consistency block). Never throws: a bad spec
19
+ * becomes a single `code:"spec"` finding. Returns ALL findings so the CLI can
20
+ * report every problem across every skill.
21
+ */
22
+ export function lintSkill(skillDir) {
23
+ const specPath = join(skillDir, "tests", "specification.yaml");
24
+ const findings = [];
25
+ let spec;
26
+ try {
27
+ spec = loadSpec(specPath);
28
+ }
29
+ catch (e) {
30
+ const message = e instanceof SpecError ? e.message : e instanceof Error ? e.message : String(e);
31
+ return [{ skill: basename(skillDir), code: "spec", message }];
32
+ }
33
+ const skill = spec.skill;
34
+ // ship_bar sanity
35
+ if (spec.ship_bar.total < 1) {
36
+ findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be >= 1" });
37
+ }
38
+ if (spec.ship_bar.min_pass < 1) {
39
+ findings.push({ skill, code: "ship_bar", message: "ship_bar.min_pass must be >= 1" });
40
+ }
41
+ if (spec.ship_bar.min_pass > spec.ship_bar.total) {
42
+ findings.push({ skill, code: "ship_bar", message: `ship_bar.min_pass (${spec.ship_bar.min_pass}) > total (${spec.ship_bar.total})` });
43
+ }
44
+ if (spec.ship_bar.total > spec.scenarios.length) {
45
+ findings.push({ skill, code: "ship_bar", message: `ship_bar.total (${spec.ship_bar.total}) > scenario count (${spec.scenarios.length})` });
46
+ }
47
+ // critical ids exist
48
+ const ids = new Set(spec.scenarios.map((s) => s.id));
49
+ for (const cid of spec.critical) {
50
+ if (!ids.has(cid))
51
+ findings.push({ skill, code: "critical", message: `critical id \`${cid}\` is not a scenario` });
52
+ }
53
+ // fixture paths exist — check the EFFECTIVE workspace fixture (what the runtime actually
54
+ // copies: run.ts uses scenario.workspace, not the raw scenario.fixture — an inline scenario
55
+ // with env.workspace: fixture:PATH sets workspace.fixture but NOT scenario.fixture). Resolve
56
+ // relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
57
+ const specDir = dirname(specPath);
58
+ for (const s of spec.scenarios) {
59
+ const fx = typeof s.workspace === "object" && s.workspace !== null ? s.workspace.fixture : undefined;
60
+ if (fx) {
61
+ const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
62
+ if (!isDir(abs)) {
63
+ findings.push({ skill, scenario: s.id, code: "fixture", message: `fixture not found: ${fx}` });
64
+ }
65
+ }
66
+ }
67
+ // results-consistency — only for committed results.yaml (skipped silently otherwise).
68
+ // Each run dir gets ONE try: schema-1 is intentionally skipped (continue, no finding —
69
+ // migrateResults carries a schema-1 grade verbatim, so recomputing it would false-flag).
70
+ // Anything else that goes wrong (unparseable YAML, or a schema-2 file that's missing/
71
+ // malformed fields — e.g. `scenarios: null`) is caught and surfaces as a `consistency`
72
+ // finding instead of throwing (lintSkill never throws) or being silently dropped (a
73
+ // broken committed artifact must fail the gate, not pass it).
74
+ const resultsRoot = join(skillDir, "tests", "results");
75
+ for (const runDir of enumerateRunDirs(resultsRoot)) {
76
+ try {
77
+ const raw = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
78
+ if (raw?.schema !== 2)
79
+ continue; // schema-1 intentionally skipped — no finding
80
+ const r = readResults(runDir);
81
+ const ctx = r.mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
82
+ const recomputed = finalizeResults({ skill: r.skill, harness: r.harness, model: r.model, judge: r.judge, timestamp: r.timestamp, label: r.label, mode: r.mode, scenarios: r.scenarios }, ctx).effective_grade;
83
+ if (JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
84
+ findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
85
+ }
86
+ for (const s of r.scenarios) {
87
+ if (s.override != null) {
88
+ if (!s.note || !s.note.trim())
89
+ findings.push({ skill, scenario: s.id, code: "consistency", message: `override on ${s.id} has no note (${runDir})` });
90
+ if (findTranscriptFiles(runDir, s.id, r.mode).length === 0)
91
+ findings.push({ skill, scenario: s.id, code: "consistency", message: `override on ${s.id} has no preserved transcript (${runDir})` });
92
+ }
93
+ }
94
+ }
95
+ catch (e) {
96
+ findings.push({ skill, code: "consistency", message: `results.yaml unreadable or malformed in ${runDir}: ${e instanceof Error ? e.message : String(e)}` });
97
+ }
98
+ }
99
+ return findings;
100
+ }
101
+ /**
102
+ * All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
103
+ * Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
104
+ * TOCTOU removal between readdir and statSync) are skipped rather than propagated, so a
105
+ * single bad entry can't abort lintSkill's "never throws" contract.
106
+ */
107
+ function enumerateRunDirs(resultsRoot) {
108
+ if (!existsSync(resultsRoot))
109
+ return [];
110
+ const out = [];
111
+ let tags;
112
+ try {
113
+ tags = readdirSync(resultsRoot);
114
+ }
115
+ catch {
116
+ return out;
117
+ }
118
+ for (const tag of tags) {
119
+ const tagDir = join(resultsRoot, tag);
120
+ if (!isDir(tagDir))
121
+ continue;
122
+ let timestamps;
123
+ try {
124
+ timestamps = readdirSync(tagDir);
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ for (const ts of timestamps) {
130
+ const runDir = join(tagDir, ts);
131
+ if (isDir(runDir) && existsSync(join(runDir, "results.yaml")))
132
+ out.push(runDir);
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+ //# sourceMappingURL=lint.js.map
@@ -0,0 +1,54 @@
1
+ import type { Spec, Scenario } from "./spec.js";
2
+ import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
3
+ import { type ScenarioResult, type ResultsFile } from "./results.js";
4
+ import { type RepOutcome } from "./reps.js";
5
+ export interface RegradeOptions {
6
+ runDir: string;
7
+ spec: Spec;
8
+ scenario: Scenario;
9
+ adapter: HarnessAdapter;
10
+ judge: ModelRef;
11
+ specDir: string;
12
+ threshold: number;
13
+ now?: () => string;
14
+ }
15
+ /** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
16
+ export declare function judgeOneRep(opts: {
17
+ runDir: string;
18
+ spec: Spec;
19
+ scenario: Scenario;
20
+ transcript: string;
21
+ adapter: HarnessAdapter;
22
+ judge: ModelRef;
23
+ specDir: string;
24
+ mode: string;
25
+ rep: number | undefined;
26
+ now: () => string;
27
+ }): Promise<RepOutcome>;
28
+ /**
29
+ * Re-judge a scenario's saved GREEN transcript(s) with `judge` — no harness
30
+ * re-run. Rewrites the judge-raw artifact per rep, emits per-rep judge-verdict
31
+ * (+ misfire-flag) journal events, and returns the aggregated ScenarioResult
32
+ * (override/note empty; the caller merges any prior override + persists).
33
+ */
34
+ export declare function regradeScenario(opts: RegradeOptions): Promise<ScenarioResult>;
35
+ export interface RegradeRunOptions {
36
+ runDir: string;
37
+ spec: Spec;
38
+ adapter: HarnessAdapter;
39
+ judge: ModelRef;
40
+ specDir: string;
41
+ now?: () => string;
42
+ }
43
+ /**
44
+ * Re-judge every green-transcript scenario in a run dir with `judge` — no
45
+ * harness re-run. Targets are the run's RECORDED scenarios (falling back to
46
+ * the spec for a run with no prior results.yaml), so re-grading rewrites the
47
+ * whole results.yaml consistently with what the run actually recorded. Each
48
+ * target must still exist in the spec (for its checklist) AND have a green
49
+ * transcript on disk; anything missing fails fast before spending any judge
50
+ * calls. Preserves each prior scenario's override/note, rewrites
51
+ * results.yaml, emits the `score` journal event, and returns the new
52
+ * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
53
+ */
54
+ export declare function regradeRun(opts: RegradeRunOptions): Promise<ResultsFile>;