@skill-harness/core 0.1.2 → 0.3.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.
@@ -14,6 +14,12 @@ export interface RunReq {
14
14
  mode: RunMode;
15
15
  turns: string[];
16
16
  cwd: string;
17
+ /**
18
+ * Abs path to a markdown file to use AS the system prompt, instead of activating
19
+ * skillDir as a skill. Used to test a subagent definition (agents/<name>.md) in the
20
+ * single-shot shape it actually runs in; overrides `mode`'s skill flags.
21
+ */
22
+ systemPromptFile?: string;
17
23
  }
18
24
  /** A judge request: single prompt, no skills, no session. */
19
25
  export interface JudgeReq {
@@ -10,5 +10,9 @@ export interface DiscoveredSkill {
10
10
  * Returns skills sorted by name (testable or not).
11
11
  */
12
12
  export declare function discover(root: string): DiscoveredSkill[];
13
- /** Resolve a single skill by name; throws a helpful error if absent or specless. */
13
+ /**
14
+ * Resolve a single skill by name; throws a helpful error if absent or specless.
15
+ * A directory that exists but lacks a SKILL.md gets a specific error (rather than
16
+ * the generic "no skill") so callers don't reimplement the SKILL.md existence check.
17
+ */
14
18
  export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
package/dist/discover.js CHANGED
@@ -24,10 +24,18 @@ export function discover(root) {
24
24
  skills.sort((a, b) => a.name.localeCompare(b.name));
25
25
  return skills;
26
26
  }
27
- /** Resolve a single skill by name; throws a helpful error if absent or specless. */
27
+ /**
28
+ * Resolve a single skill by name; throws a helpful error if absent or specless.
29
+ * A directory that exists but lacks a SKILL.md gets a specific error (rather than
30
+ * the generic "no skill") so callers don't reimplement the SKILL.md existence check.
31
+ */
28
32
  export function resolveSkill(root, name) {
29
33
  const skill = discover(root).find((s) => s.name === name);
30
34
  if (!skill) {
35
+ const dir = join(root, name);
36
+ if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
37
+ throw new Error(`skill \`${name}\` has no SKILL.md (looked in ${dir})`);
38
+ }
31
39
  throw new Error(`no skill \`${name}\` under ${root}`);
32
40
  }
33
41
  return skill;
package/dist/grade.d.ts CHANGED
@@ -7,13 +7,26 @@ export interface JudgePromptInput {
7
7
  scenario: Scenario;
8
8
  transcript: string;
9
9
  }
10
+ /** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
11
+ export declare const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
10
12
  /** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
11
13
  export declare function buildJudgePrompt(input: JudgePromptInput): string;
12
14
  export interface ParsedVerdict {
13
15
  verdict: Verdict;
14
16
  reason: string;
15
17
  }
16
- /** Parse a judge's raw output into a verdict + reason. Unparseable → ERROR. */
18
+ /**
19
+ * Parse a judge's raw output into a verdict + reason.
20
+ *
21
+ * Judges sometimes emit MORE than one verdict block (a first pass, then a restated
22
+ * conclusion). Every block is read, never just the first:
23
+ * - all blocks agree → that verdict, with the reason from the LAST block (the
24
+ * judge's final word) in full
25
+ * - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
26
+ * verdicts in the reason so a rejudge can be queued. Silently taking either one
27
+ * would be inventing a grade the judge did not give.
28
+ * Unparseable → ERROR.
29
+ */
17
30
  export declare function parseVerdict(out: string): ParsedVerdict;
18
31
  /**
19
32
  * Judge-≠-subject de-confound guard. True when the judge resembles the model
package/dist/grade.js CHANGED
@@ -1,8 +1,33 @@
1
1
  import { createWorkspace } from "./workspace.js";
2
+ /** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
3
+ export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
4
+ /**
5
+ * Addendum pointing the judge at the code, added only when the code is actually there.
6
+ *
7
+ * A seeded transcript ends with the staged diff, and without this the judge
8
+ * weighs the model's prose about its work equally with the work itself — which
9
+ * is how six reps that all passed the objective gates split PASS/FAIL purely on
10
+ * whether the model wrote "rejects overdrafts" or "subtracts amount".
11
+ *
12
+ * Gated on the transcript CONTAINING the diff section, not on `scenario.mode`.
13
+ * The first sentence is a factual claim about the transcript, and every seeded
14
+ * transcript saved before this feature existed lacks that section — so keying
15
+ * off the mode would tell the judge its primary evidence is at the end of a
16
+ * transcript that has none, while also telling it the gate lines prove nothing.
17
+ * That is a sweep of FAILs justified by absent evidence, and it would land
18
+ * squarely on `grade`, the command AGENTS.md rule 4 recommends as the *cheap*
19
+ * de-confounding step before re-running. Inline scenarios have no diff either,
20
+ * so their prompt stays byte-identical and every published inline verdict
21
+ * remains comparable.
22
+ */
23
+ const SEEDED_DIFF_GUIDANCE = `
24
+ This transcript ends with a "=== STAGED DIFF ===" section: the actual code the assistant wrote, as \`git diff --cached\`. It is the primary evidence. Grade what the diff shows the code DOES, not what the assistant's prose claims it does — a confident description of behavior the diff does not implement is a FAIL, and behavior the diff plainly implements passes even if the assistant described it poorly or not at all. The "=== SEEDED GATES ===" lines above it are keyword and test-run checks only; they do not establish that the required behavior exists. If the diff is marked truncated, judge only what you can see and never infer that cut-off code is missing.
25
+ `;
2
26
  /** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
3
27
  export function buildJudgePrompt(input) {
4
28
  const { skill, persona, scenario, transcript } = input;
5
29
  const numbered = scenario.checklist.map((c, i) => `${i + 1}. ${c}`).join("\n");
30
+ const diffGuidance = scenario.mode === "seeded" && transcript.includes(STAGED_DIFF_HEADING) ? SEEDED_DIFF_GUIDANCE : "";
6
31
  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
32
 
8
33
  CHECKLIST (every numbered item must hold for a PASS):
@@ -10,23 +35,44 @@ ${numbered}
10
35
 
11
36
  TRANSCRIPT (the assistant is the model under test):
12
37
  ${transcript}
13
-
38
+ ${diffGuidance}
14
39
  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
40
  VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
16
41
  REASON: <15 words or fewer>`;
17
42
  }
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. */
43
+ // Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
44
+ // reason pattern matched any word containing "reason" — git-ops GLM C1 stored
45
+ // "able given no repo present.", a fragment of "Reasonable" in the judge's prose, which
46
+ // then read as a FAIL-verdict-with-passing-reason misfire that never happened.
47
+ const VERDICT_RE = /^\s*\**\s*VERDICT\**\s*:\s*\**\s*(PASS|FAIL)/gim;
48
+ const REASON_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/gim;
49
+ /**
50
+ * Parse a judge's raw output into a verdict + reason.
51
+ *
52
+ * Judges sometimes emit MORE than one verdict block (a first pass, then a restated
53
+ * conclusion). Every block is read, never just the first:
54
+ * - all blocks agree → that verdict, with the reason from the LAST block (the
55
+ * judge's final word) in full
56
+ * - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
57
+ * verdicts in the reason so a rejudge can be queued. Silently taking either one
58
+ * would be inventing a grade the judge did not give.
59
+ * Unparseable → ERROR.
60
+ */
21
61
  export function parseVerdict(out) {
22
- const vm = out.match(VERDICT_RE);
23
- if (!vm) {
62
+ const verdicts = [...out.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
63
+ if (verdicts.length === 0) {
24
64
  return { verdict: "ERROR", reason: "judge produced no parseable verdict" };
25
65
  }
26
- const verdict = vm[1].toUpperCase();
27
- const rm = out.match(REASON_RE);
28
- const reason = rm ? rm[1].trim() : "";
29
- return { verdict, reason };
66
+ const reasons = [...out.matchAll(REASON_RE)].map((m) => m[1].trim());
67
+ const reason = reasons.length > 0 ? reasons[reasons.length - 1] : "";
68
+ const unique = [...new Set(verdicts)];
69
+ if (unique.length > 1) {
70
+ return {
71
+ verdict: "JUDGE-AMBIGUOUS",
72
+ reason: `judge emitted conflicting verdicts (${verdicts.join(", ")}) — needs rejudge; last reason: ${reason}`,
73
+ };
74
+ }
75
+ return { verdict: unique[0], reason };
30
76
  }
31
77
  /**
32
78
  * Judge-≠-subject de-confound guard. True when the judge resembles the model
@@ -51,13 +97,30 @@ const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
51
97
  export function detectMisfire(raw, verdict) {
52
98
  if (verdict === "ERROR")
53
99
  return false;
100
+ // Conflicting verdicts are suspect by construction — there is no consistent grade.
101
+ if (verdict === "JUDGE-AMBIGUOUS")
102
+ return true;
54
103
  const items = [...raw.matchAll(ITEM_RE)].map((m) => m[1].toUpperCase() === "PASS");
55
- if (items.length === 0)
104
+ if (items.length === 0) {
105
+ // No item lines to cross-check, so fall back to the verdict-vs-reason shape: a FAIL
106
+ // whose reason says everything passed is the misfire class from REVIEW-FINDINGS
107
+ // finding 2. Deliberately narrow — an earlier version of this tripwire fired on
108
+ // terse genuine FAILs, so it requires an explicitly total claim ("all items ...
109
+ // pass", "every item ... satisfied") and no negation anywhere in the reason.
110
+ if (verdict === "FAIL") {
111
+ const reason = (raw.match(REASON_LINE_RE)?.[1] ?? "").trim();
112
+ const totalPass = /\b(all|every)\b[^.]*\b(pass(es|ed)?|satisf(y|ies|ied)|hold(s)?|met)\b/i.test(reason);
113
+ const negated = /\b(not|no|n't|fails?|failed|missing|except|but|however)\b/i.test(reason);
114
+ return totalPass && !negated;
115
+ }
56
116
  return false; // fail-open
117
+ }
57
118
  const andItems = items.every((ok) => ok);
58
119
  const verdictBool = verdict === "PASS";
59
120
  return verdictBool !== andItems;
60
121
  }
122
+ // Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
123
+ const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
61
124
  /** Drive the judge for one transcript and parse the result. */
62
125
  export async function gradeTranscript(adapter, judge, prompt, cwd) {
63
126
  const raw = await adapter.judge({ model: judge, prompt, cwd });
package/dist/index.d.ts CHANGED
@@ -8,10 +8,15 @@ export * from "./journal.js";
8
8
  export * from "./scheduler.js";
9
9
  export * from "./reps.js";
10
10
  export * from "./regrade.js";
11
+ export * from "./rescore.js";
11
12
  export * from "./workspace.js";
12
13
  export * from "./seeded.js";
13
14
  export * from "./report.js";
14
15
  export * from "./trends.js";
15
16
  export * from "./lint.js";
17
+ export * from "./sources.js";
18
+ export * from "./lift.js";
16
19
  export * from "./adapters/types.js";
17
20
  export * from "./util/exec.js";
21
+ export * from "./util/env.js";
22
+ export * from "./scaffold.js";
package/dist/index.js CHANGED
@@ -8,11 +8,16 @@ export * from "./journal.js";
8
8
  export * from "./scheduler.js";
9
9
  export * from "./reps.js";
10
10
  export * from "./regrade.js";
11
+ export * from "./rescore.js";
11
12
  export * from "./workspace.js";
12
13
  export * from "./seeded.js";
13
14
  export * from "./report.js";
14
15
  export * from "./trends.js";
15
16
  export * from "./lint.js";
17
+ export * from "./sources.js";
18
+ export * from "./lift.js";
16
19
  export * from "./adapters/types.js";
17
20
  export * from "./util/exec.js";
21
+ export * from "./util/env.js";
22
+ export * from "./scaffold.js";
18
23
  //# sourceMappingURL=index.js.map
package/dist/journal.d.ts CHANGED
@@ -54,6 +54,20 @@ export type JournalEvent = {
54
54
  id: string;
55
55
  reason: string;
56
56
  rep?: number;
57
+ } | {
58
+ event: "empty-response-retry";
59
+ ts: string;
60
+ id: string;
61
+ attempt: number;
62
+ rep?: number;
63
+ } | {
64
+ event: "rescore";
65
+ ts: string;
66
+ changed: string[];
67
+ passed: number;
68
+ total: number;
69
+ pct: number;
70
+ ship: boolean;
57
71
  } | {
58
72
  event: "score";
59
73
  ts: string;
package/dist/lift.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { type ResultsFile } from "./results.js";
2
+ import type { Verdict } from "./score.js";
3
+ /**
4
+ * What a skill did to one scenario, red (skill off) vs green (skill active).
5
+ *
6
+ * `inconclusive` is load-bearing: it is what stops lift from becoming a number
7
+ * that only ever goes up. See `classify`.
8
+ */
9
+ export type LiftClass = "gained" | "regressed" | "kept" | "both-fail" | "inconclusive";
10
+ export interface LiftCell {
11
+ red: Verdict;
12
+ /**
13
+ * Whether the red side's verdict was an unresolved misfire. Carried (not just
14
+ * folded into `class`) because the review UI recomputes the class live as the
15
+ * author overrides green verdicts — it needs the red evidence, not a frozen
16
+ * conclusion. See assets/report.grade.js's liftClass.
17
+ */
18
+ redSuspect: boolean;
19
+ green: Verdict;
20
+ class: LiftClass;
21
+ }
22
+ export interface Lift {
23
+ tag: string;
24
+ model: string;
25
+ redTimestamp: string;
26
+ greenTimestamp: string;
27
+ /** Scenario ids present in both runs — the only ones a lift can speak to. */
28
+ compared: number;
29
+ gained: number;
30
+ regressed: number;
31
+ kept: number;
32
+ bothFail: number;
33
+ inconclusive: number;
34
+ redPassed: number;
35
+ greenPassed: number;
36
+ delta: number;
37
+ /** Ids the green run covered that the red baseline did not (and vice versa). */
38
+ greenOnly: string[];
39
+ redOnly: string[];
40
+ /** True when either side was an `--only` run, so coverage is a subset by construction. */
41
+ partial: boolean;
42
+ cells: Record<string, LiftCell>;
43
+ }
44
+ /**
45
+ * Compare a red (baseline, skill off) run against a green (skill active) run of
46
+ * the same model: the "does this skill actually do anything?" measurement.
47
+ *
48
+ * Both sides go through `effectiveVerdicts`, so an author override is what
49
+ * counts and an override resolves a misfire — the same rule scoring uses. Only
50
+ * the intersection of scenario ids is compared; a lift cannot speak to a
51
+ * scenario one side never ran.
52
+ */
53
+ export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
54
+ /** One line for a human: what the skill did, and what it cost. */
55
+ export declare function liftHeadline(lift: Lift): string;
56
+ /**
57
+ * Per model-tag under <skillDir>/tests/results/, pair the most recent red run
58
+ * with the most recent green run and compute the lift.
59
+ *
60
+ * Deliberately derived on read rather than persisted into results.yaml: a lift
61
+ * is a fact about a *pair* of runs, so caching it inside one run's file would go
62
+ * stale the moment a new baseline lands — the stale-scorecard failure mode
63
+ * `source_hashes` exists to prevent. Deriving also means lift works
64
+ * retroactively on results already committed by 0.1.x/0.2.0 users.
65
+ *
66
+ * A tag with no red baseline is omitted entirely rather than reported as a zero
67
+ * lift: "not measured" and "measured no effect" are different claims.
68
+ */
69
+ export declare function collectLift(skillDir: string): Lift[];
package/dist/lift.js ADDED
@@ -0,0 +1,163 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readResults, effectiveVerdicts } from "./results.js";
4
+ /** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
5
+ function conclusive(verdict, suspect) {
6
+ // ERROR is a harness failure (timeout, empty reply) — it says nothing about
7
+ // whether the agent could do the task. JUDGE-AMBIGUOUS and an unresolved
8
+ // misfire are judge failures. Treating any of them as a FAIL would make
9
+ // "red FAIL -> green PASS" fire on infrastructure noise, and lift would
10
+ // measure flakiness while looking like skill value.
11
+ return !suspect && verdict !== "ERROR" && verdict !== "JUDGE-AMBIGUOUS";
12
+ }
13
+ function classify(red, green) {
14
+ if (!conclusive(red.verdict, red.suspect) || !conclusive(green.verdict, green.suspect))
15
+ return "inconclusive";
16
+ const redPass = red.verdict === "PASS";
17
+ const greenPass = green.verdict === "PASS";
18
+ if (redPass && greenPass)
19
+ return "kept";
20
+ if (!redPass && greenPass)
21
+ return "gained";
22
+ if (redPass && !greenPass)
23
+ return "regressed";
24
+ return "both-fail";
25
+ }
26
+ /**
27
+ * Compare a red (baseline, skill off) run against a green (skill active) run of
28
+ * the same model: the "does this skill actually do anything?" measurement.
29
+ *
30
+ * Both sides go through `effectiveVerdicts`, so an author override is what
31
+ * counts and an override resolves a misfire — the same rule scoring uses. Only
32
+ * the intersection of scenario ids is compared; a lift cannot speak to a
33
+ * scenario one side never ran.
34
+ */
35
+ export function computeLift(red, green) {
36
+ const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
37
+ const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
38
+ const cells = {};
39
+ const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
40
+ let redPassed = 0;
41
+ let greenPassed = 0;
42
+ // Green order drives display order (it is the run the author is looking at),
43
+ // restricted to ids the red baseline also covered.
44
+ for (const [id, g] of greenV) {
45
+ const r = redV.get(id);
46
+ if (!r)
47
+ continue;
48
+ const cls = classify(r, g);
49
+ cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
50
+ counts[cls]++;
51
+ if (cls !== "inconclusive") {
52
+ if (r.verdict === "PASS")
53
+ redPassed++;
54
+ if (g.verdict === "PASS")
55
+ greenPassed++;
56
+ }
57
+ }
58
+ return {
59
+ tag: "",
60
+ model: green.model,
61
+ redTimestamp: red.timestamp,
62
+ greenTimestamp: green.timestamp,
63
+ compared: Object.keys(cells).length,
64
+ gained: counts.gained,
65
+ regressed: counts.regressed,
66
+ kept: counts.kept,
67
+ bothFail: counts["both-fail"],
68
+ inconclusive: counts.inconclusive,
69
+ redPassed,
70
+ greenPassed,
71
+ delta: greenPassed - redPassed,
72
+ greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
73
+ redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
74
+ partial: Boolean(red.partial || green.partial),
75
+ cells,
76
+ };
77
+ }
78
+ /** One line for a human: what the skill did, and what it cost. */
79
+ export function liftHeadline(lift) {
80
+ if (lift.compared === 0)
81
+ return "no shared scenarios to compare";
82
+ // Everything inconclusive is NOT "no effect" — it is no measurement. Saying
83
+ // "no measured effect" here would be the same not-measured/measured-no-effect
84
+ // conflation this module refuses to make when a red baseline is missing.
85
+ const conclusive = lift.compared - lift.inconclusive;
86
+ if (conclusive === 0) {
87
+ return `nothing conclusive to compare (${lift.inconclusive} inconclusive — fix the harness/judge, then re-run)`;
88
+ }
89
+ const segments = [];
90
+ if (lift.gained === 0 && lift.regressed === 0) {
91
+ segments.push(lift.kept > 0
92
+ ? `no measured effect (${lift.kept} passed without the skill too)`
93
+ : "no measured effect");
94
+ }
95
+ else {
96
+ const sign = lift.delta > 0 ? `+${lift.delta}` : String(lift.delta);
97
+ segments.push(`${sign} net (${lift.gained} gained, ${lift.regressed} regressed)`);
98
+ }
99
+ if (lift.inconclusive > 0)
100
+ segments.push(`${lift.inconclusive} inconclusive`);
101
+ if (lift.partial)
102
+ segments.push("partial run");
103
+ return segments.join(" · ");
104
+ }
105
+ /** A directory that exists right now; false (never throws) if it vanished concurrently. */
106
+ function isDir(p) {
107
+ try {
108
+ return statSync(p).isDirectory();
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /**
115
+ * Per model-tag under <skillDir>/tests/results/, pair the most recent red run
116
+ * with the most recent green run and compute the lift.
117
+ *
118
+ * Deliberately derived on read rather than persisted into results.yaml: a lift
119
+ * is a fact about a *pair* of runs, so caching it inside one run's file would go
120
+ * stale the moment a new baseline lands — the stale-scorecard failure mode
121
+ * `source_hashes` exists to prevent. Deriving also means lift works
122
+ * retroactively on results already committed by 0.1.x/0.2.0 users.
123
+ *
124
+ * A tag with no red baseline is omitted entirely rather than reported as a zero
125
+ * lift: "not measured" and "measured no effect" are different claims.
126
+ */
127
+ export function collectLift(skillDir) {
128
+ const resultsRoot = join(skillDir, "tests", "results");
129
+ if (!existsSync(resultsRoot))
130
+ return [];
131
+ const lifts = [];
132
+ for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
133
+ const tagDir = join(resultsRoot, tag);
134
+ const runDirs = readdirSync(tagDir)
135
+ .map((n) => join(tagDir, n))
136
+ .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
137
+ .sort(); // timestamp-slug names ⇒ chronological ascending
138
+ // Mode is only knowable after reading results.yaml, so every run in the tag
139
+ // is read; last-wins gives the most recent of each mode.
140
+ let red;
141
+ let green;
142
+ for (const rd of runDirs) {
143
+ let r;
144
+ try {
145
+ r = readResults(rd);
146
+ }
147
+ catch (e) {
148
+ // A corrupt/truncated results.yaml must not take down the whole view.
149
+ console.warn(`skill-harness lift: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
150
+ continue;
151
+ }
152
+ if (r.mode === "red")
153
+ red = r;
154
+ else if (r.mode === "green")
155
+ green = r;
156
+ }
157
+ if (!red || !green)
158
+ continue;
159
+ lifts.push({ ...computeLift(red, green), tag });
160
+ }
161
+ return lifts;
162
+ }
163
+ //# sourceMappingURL=lift.js.map
package/dist/lint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "lint-error";
1
+ export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "lint-error";
2
2
  export interface LintFinding {
3
3
  readonly skill: string;
4
4
  readonly scenario?: string;