@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 +21 -0
- package/dist/adapters/types.d.ts +29 -0
- package/dist/adapters/types.js +18 -0
- package/dist/discover.d.ts +14 -0
- package/dist/discover.js +35 -0
- package/dist/grade.d.ts +44 -0
- package/dist/grade.js +89 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +18 -0
- package/dist/journal.d.ts +76 -0
- package/dist/journal.js +32 -0
- package/dist/lint.d.ts +14 -0
- package/dist/lint.js +137 -0
- package/dist/regrade.d.ts +54 -0
- package/dist/regrade.js +107 -0
- package/dist/report.d.ts +80 -0
- package/dist/report.js +106 -0
- package/dist/reps.d.ts +34 -0
- package/dist/reps.js +45 -0
- package/dist/results.d.ts +108 -0
- package/dist/results.js +241 -0
- package/dist/run.d.ts +28 -0
- package/dist/run.js +148 -0
- package/dist/scheduler.d.ts +11 -0
- package/dist/scheduler.js +27 -0
- package/dist/score.d.ts +33 -0
- package/dist/score.js +62 -0
- package/dist/seeded.d.ts +21 -0
- package/dist/seeded.js +45 -0
- package/dist/spec.d.ts +39 -0
- package/dist/spec.js +175 -0
- package/dist/trends.d.ts +54 -0
- package/dist/trends.js +102 -0
- package/dist/util/exec.d.ts +14 -0
- package/dist/util/exec.js +43 -0
- package/dist/workspace.d.ts +17 -0
- package/dist/workspace.js +42 -0
- package/package.json +17 -0
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;
|
package/dist/discover.js
ADDED
|
@@ -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
|
package/dist/grade.d.ts
ADDED
|
@@ -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
|
package/dist/index.d.ts
ADDED
|
@@ -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[];
|
package/dist/journal.js
ADDED
|
@@ -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>;
|