@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.
package/dist/results.js CHANGED
@@ -46,7 +46,8 @@ export function finalizeResults(draft, ctx) {
46
46
  effective_grade = { passed: s.passed, total: s.total, pct: s.pct, letter: s.letter, ship: s.ship, note: s.note };
47
47
  }
48
48
  else {
49
- effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: `mode=${draft.mode} (not scored)` };
49
+ const why = draft.partial ? "partial run (--only) not scored" : `mode=${draft.mode} (not scored)`;
50
+ effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: why };
50
51
  }
51
52
  return {
52
53
  schema: 2,
@@ -57,6 +58,8 @@ export function finalizeResults(draft, ctx) {
57
58
  timestamp: draft.timestamp,
58
59
  label: draft.label,
59
60
  mode: draft.mode,
61
+ ...(draft.partial ? { partial: true } : {}),
62
+ ...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
60
63
  effective_grade,
61
64
  scenarios: draft.scenarios,
62
65
  };
@@ -147,9 +150,10 @@ export function ensureResultsGitignore(resultsRoot) {
147
150
  .filter((l) => l.startsWith("!") && l.trim() !== "!results.yaml");
148
151
  writeFileSync(giPath, GITIGNORE_BODY + preserved.map((l) => l + "\n").join(""), "utf8");
149
152
  }
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
+ // Matches transcript (`.rep<k>.txt`), judge-raw (`.rep<k>.judge.txt`) and
154
+ // staged-diff (`.rep<k>.diff.txt`) rep suffixes.
155
+ const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?txt$/;
156
+ /** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
153
157
  export function repIndexOf(filename) {
154
158
  const m = REP_SUFFIX_RE.exec(filename);
155
159
  return m ? Number(m[1]) : null;
@@ -178,7 +182,10 @@ function sortByRep(files) {
178
182
  * `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
179
183
  * false positives from a red/force transcript of the same scenario. Omitted,
180
184
  * behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
181
- * scenario's judge-raw artifacts (`<id>.*.judge.txt` see judgeRawPath).
185
+ * scenario's sibling artifacts, which share the `.txt` extension deliberately
186
+ * (so `results/.gitignore`'s `*.txt` covers them all): judge-raw output
187
+ * (`<id>.*.judge.txt` — see judgeRawPath) and the staged diff
188
+ * (`<id>.*.diff.txt` — see diffPath).
182
189
  */
183
190
  export function findTranscriptFiles(runDir, scenarioId, mode) {
184
191
  if (!existsSync(runDir))
@@ -187,7 +194,12 @@ export function findTranscriptFiles(runDir, scenarioId, mode) {
187
194
  const matcher = mode !== undefined
188
195
  ? new RegExp(`^${escapedId}\\.${mode}(\\.rep\\d+)?\\.txt$`)
189
196
  : null;
190
- const files = readdirSync(runDir).filter((f) => matcher ? matcher.test(f) : f.startsWith(`${scenarioId}.`) && f.endsWith(".txt") && !f.endsWith(".judge.txt"));
197
+ const files = readdirSync(runDir).filter((f) => matcher
198
+ ? matcher.test(f)
199
+ : f.startsWith(`${scenarioId}.`) &&
200
+ f.endsWith(".txt") &&
201
+ !f.endsWith(".judge.txt") &&
202
+ !f.endsWith(".diff.txt"));
191
203
  return sortByRep(files);
192
204
  }
193
205
  /** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
@@ -205,22 +217,54 @@ export function findJudgeRawFiles(runDir, scenarioId, mode) {
205
217
  : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\.txt$`);
206
218
  return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
207
219
  }
220
+ /**
221
+ * Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
222
+ * for reps).
223
+ *
224
+ * The diff is the only record of what the model actually *did* — the workspace is
225
+ * torn down after every rep, so without this a seeded verdict cannot be audited
226
+ * after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
227
+ * transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
228
+ * generated evidence, ignored like transcripts, not committed like results.yaml.
229
+ */
230
+ export function diffPath(runDir, scenarioId, mode, rep) {
231
+ const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
232
+ return join(runDir, `${base}.diff.txt`);
233
+ }
234
+ /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
235
+ export function findDiffFiles(runDir, scenarioId, mode) {
236
+ if (!existsSync(runDir))
237
+ return [];
238
+ const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+ const re = mode === undefined
240
+ ? new RegExp(`^${esc}\\..*\\.diff\\.txt$`)
241
+ : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.diff\\.txt$`);
242
+ return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
243
+ }
208
244
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
209
245
  export function findTranscriptFile(runDir, scenarioId) {
210
246
  return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
211
247
  }
212
248
  /**
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).
249
+ * Un-gitignore ALL of a scenario's transcript, judge-raw AND staged-diff
250
+ * artifact files (audit trail for an override — a --reps run has one of each
251
+ * per rep, and every rep that drove the verdict must survive a commit, not just
252
+ * an arbitrary one).
217
253
  * 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).
254
+ * `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
255
+ * uses POSIX separators so the negation matches on Windows too (git ignore
256
+ * patterns are always forward-slashed).
257
+ *
258
+ * The diff belongs here for the same reason the judge-raw output does: an
259
+ * override says the judge got it wrong, and on a seeded scenario the evidence
260
+ * for that claim is the code the model wrote.
221
261
  */
222
262
  export function preserveTranscript(resultsRoot, runDir, scenarioId) {
223
- const files = [...findTranscriptFiles(runDir, scenarioId), ...findJudgeRawFiles(runDir, scenarioId)];
263
+ const files = [
264
+ ...findTranscriptFiles(runDir, scenarioId),
265
+ ...findJudgeRawFiles(runDir, scenarioId),
266
+ ...findDiffFiles(runDir, scenarioId),
267
+ ];
224
268
  if (files.length === 0)
225
269
  return;
226
270
  ensureResultsGitignore(resultsRoot);
package/dist/run.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Spec } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
3
  import { type ResultsFile } from "./results.js";
4
+ import { type Lift } from "./lift.js";
4
5
  export interface RunOptions {
5
6
  spec: Spec;
6
7
  skillDir: string;
@@ -17,6 +18,12 @@ export interface RunOptions {
17
18
  concurrency?: number;
18
19
  reps?: number;
19
20
  passThreshold?: number;
21
+ /**
22
+ * Run only these scenario ids — the iteration tool (re-testing 2 D-scenarios must not
23
+ * cost an 18-scenario run). The result is marked `partial: true` and is NEVER
24
+ * ship-graded: a subset passing says nothing about the ship bar.
25
+ */
26
+ only?: string[];
20
27
  }
21
28
  export interface RunSummary {
22
29
  runDir: string;
@@ -24,5 +31,22 @@ export interface RunSummary {
24
31
  }
25
32
  /** Run one skill against one model: run scenarios, grade, score, persist. */
26
33
  export declare function runSkillModel(opts: RunOptions): Promise<RunSummary>;
34
+ /**
35
+ * True when any assistant turn in a transcript is blank — the shape a harness timeout
36
+ * leaves behind. Such a transcript must never reach the judge: grading an empty reply
37
+ * produces a confident FAIL about behavior that never happened (round 9 lost two
38
+ * scenarios this way). Sections are delimited by the adapters' shared transcript
39
+ * convention (">>> USER"/"<<< ASSISTANT:"); seeded gate output ("=== SEEDED GATES ===")
40
+ * ends the last assistant section.
41
+ */
42
+ export declare function hasEmptyAssistantTurn(transcript: string): boolean;
27
43
  /** A compact terminal scorecard for one run. */
28
- export declare function formatScorecard(summary: RunSummary): string;
44
+ /**
45
+ * The human-facing scorecard for one run.
46
+ *
47
+ * `lift` is the red-vs-green comparison for this model when a red baseline
48
+ * exists. Passing it for a green run turns the scorecard from "the skill scored
49
+ * B" into "the skill *did* this much" — without a baseline the grade alone can't
50
+ * distinguish a skill that works from a model that never needed it.
51
+ */
52
+ export declare function formatScorecard(summary: RunSummary, lift?: Lift): string;
package/dist/run.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
2
+ import { dirname, resolve } from "node:path";
3
+ import { sourceHashes } from "./sources.js";
3
4
  import { judgeResemblesSubject } from "./grade.js";
4
- import { runDirFor, transcriptPath, writeResults, ensureResultsGitignore, } from "./results.js";
5
+ import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
5
6
  import { appendJournal } from "./journal.js";
7
+ import { liftHeadline } from "./lift.js";
6
8
  import { runSeeded } from "./seeded.js";
7
9
  import { createWorkspace } from "./workspace.js";
8
10
  import { runPool } from "./scheduler.js";
@@ -13,6 +15,20 @@ export async function runSkillModel(opts) {
13
15
  const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
14
16
  const log = opts.onProgress ?? (() => { });
15
17
  const now = opts.now ?? (() => new Date().toISOString());
18
+ // --only: validate against the spec BEFORE spending anything — a typo'd id must not
19
+ // silently run zero scenarios and report success.
20
+ let scenarios = spec.scenarios;
21
+ const partial = Boolean(opts.only && opts.only.length > 0);
22
+ if (partial) {
23
+ const known = new Set(spec.scenarios.map((s) => s.id));
24
+ const unknown = opts.only.filter((id) => !known.has(id));
25
+ if (unknown.length > 0) {
26
+ throw new Error(`--only names unknown scenario id(s) ${unknown.join(", ")} — spec has: ${[...known].join(", ")}`);
27
+ }
28
+ const wanted = new Set(opts.only);
29
+ scenarios = spec.scenarios.filter((s) => wanted.has(s.id));
30
+ log(` --only ${opts.only.join(",")} — partial run, will not be ship-graded`);
31
+ }
16
32
  if (judgeResemblesSubject(judge, model)) {
17
33
  log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
18
34
  `(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
@@ -27,10 +43,10 @@ export async function runSkillModel(opts) {
27
43
  mode, label: opts.label ?? null,
28
44
  });
29
45
  // 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);
46
+ const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
31
47
  const owners = [];
32
48
  const tasks = [];
33
- spec.scenarios.forEach((scenario, si) => {
49
+ scenarios.forEach((scenario, si) => {
34
50
  for (let k = 0; k < repCounts[si]; k++) {
35
51
  const rep = k;
36
52
  const total = repCounts[si];
@@ -39,13 +55,13 @@ export async function runSkillModel(opts) {
39
55
  }
40
56
  });
41
57
  const flat = await runPool(tasks, opts.concurrency ?? 1);
42
- const grouped = spec.scenarios.map(() => []);
58
+ const grouped = scenarios.map(() => []);
43
59
  flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
44
- const scenarioResults = spec.scenarios.map((scenario, si) => {
60
+ const scenarioResults = scenarios.map((scenario, si) => {
45
61
  const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
46
62
  return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
47
63
  });
48
- const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
64
+ const ctx = mode === "green" && !partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
49
65
  const results = writeResults(runDir, {
50
66
  skill: spec.skill,
51
67
  harness: adapter.name,
@@ -54,6 +70,10 @@ export async function runSkillModel(opts) {
54
70
  timestamp,
55
71
  label: opts.label ?? null,
56
72
  mode,
73
+ ...(partial ? { partial: true } : {}),
74
+ // Only the scenarios this run actually measured: a --only run must not claim
75
+ // coverage of scenarios it skipped.
76
+ source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios }),
57
77
  scenarios: scenarioResults,
58
78
  }, ctx);
59
79
  if (ctx) {
@@ -62,6 +82,23 @@ export async function runSkillModel(opts) {
62
82
  }
63
83
  return { runDir, results };
64
84
  }
85
+ /**
86
+ * True when any assistant turn in a transcript is blank — the shape a harness timeout
87
+ * leaves behind. Such a transcript must never reach the judge: grading an empty reply
88
+ * produces a confident FAIL about behavior that never happened (round 9 lost two
89
+ * scenarios this way). Sections are delimited by the adapters' shared transcript
90
+ * convention (">>> USER"/"<<< ASSISTANT:"); seeded gate output ("=== SEEDED GATES ===")
91
+ * ends the last assistant section.
92
+ */
93
+ export function hasEmptyAssistantTurn(transcript) {
94
+ const sections = transcript.split(/^<<< ASSISTANT:\s*$/m).slice(1);
95
+ if (sections.length === 0)
96
+ return false;
97
+ return sections.some((sec) => {
98
+ const body = sec.split(/^(?:>>> |=== SEEDED GATES ===|\[pi exited )/m)[0];
99
+ return body.trim() === "";
100
+ });
101
+ }
65
102
  /** Run ONE rep of a scenario in its own isolated workspace. */
66
103
  async function runRep(scenario, rep, repCount, ctx) {
67
104
  const { spec, judge, mode, runDir, now, log } = ctx;
@@ -73,37 +110,75 @@ async function runRep(scenario, rep, repCount, ctx) {
73
110
  let ws = null;
74
111
  let transcript = "";
75
112
  let gatePrefix = null;
113
+ // Null until a seeded rep actually reaches its gates: a workspace-setup failure
114
+ // produces no diff, and writing an empty artifact there would misreport "the
115
+ // model changed nothing" for a rep that never ran.
116
+ let stagedDiff = null;
76
117
  try {
77
118
  try {
78
- ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath) });
119
+ ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
79
120
  }
80
121
  catch (e) {
81
122
  // A setup failure (e.g. missing fixture) is an objective FAIL, not an infra abort.
82
123
  gatePrefix = e instanceof Error ? e.message : String(e);
83
124
  transcript = `[workspace setup failed] ${gatePrefix}`;
84
125
  }
126
+ let noResponse = false;
85
127
  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
- });
128
+ // A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
129
+ // fresh workspace (the first attempt may have half-mutated a seeded repo), and if
130
+ // it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
131
+ for (let attempt = 0; attempt < 2; attempt++) {
132
+ if (attempt > 0) {
133
+ appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, ...repField });
134
+ log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
135
+ ws.cleanup();
136
+ ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
137
+ }
138
+ if (scenario.mode === "seeded") {
139
+ const r = await runSeeded(scenario, {
140
+ skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
141
+ specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
142
+ });
143
+ transcript = r.transcript;
144
+ gatePrefix = r.gateFailure;
145
+ stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
146
+ }
147
+ else {
148
+ transcript = await ctx.adapter.run({
149
+ skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
150
+ // resolved like fixtures: relative to the spec's dir
151
+ systemPromptFile: scenario.systemPromptFile
152
+ ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
153
+ : undefined,
154
+ });
155
+ }
156
+ noResponse = hasEmptyAssistantTurn(transcript);
157
+ if (!noResponse)
158
+ break;
97
159
  }
98
160
  }
99
- writeFileSync(transcriptPath(runDir, scenario.id, mode, repCount > 1 ? rep : undefined), transcript, "utf8");
161
+ const repSuffix = repCount > 1 ? rep : undefined;
162
+ writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
100
163
  if (scenario.mode === "seeded") {
164
+ // The workspace is torn down in the `finally` below, so this is the only
165
+ // chance to keep what the model actually wrote. Persisted uncapped (the
166
+ // transcript's copy is capped for the judge) and for every rep, pass or
167
+ // fail — a gate failure is exactly when you want to read the diff.
168
+ if (stagedDiff !== null) {
169
+ writeFileSync(diffPath(runDir, scenario.id, mode, repSuffix), stagedDiff, "utf8");
170
+ }
101
171
  appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
102
172
  }
103
173
  let verdict;
104
174
  let reason;
105
175
  let suspect = false;
106
- if (gatePrefix) {
176
+ if (noResponse) {
177
+ verdict = "ERROR";
178
+ reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
179
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
180
+ }
181
+ else if (gatePrefix) {
107
182
  verdict = "FAIL";
108
183
  reason = gatePrefix;
109
184
  // gate failures don't invoke the judge, but still record a judge-verdict event (as before)
@@ -126,7 +201,15 @@ async function runRep(scenario, rep, repCount, ctx) {
126
201
  }
127
202
  }
128
203
  /** A compact terminal scorecard for one run. */
129
- export function formatScorecard(summary) {
204
+ /**
205
+ * The human-facing scorecard for one run.
206
+ *
207
+ * `lift` is the red-vs-green comparison for this model when a red baseline
208
+ * exists. Passing it for a green run turns the scorecard from "the skill scored
209
+ * B" into "the skill *did* this much" — without a baseline the grade alone can't
210
+ * distinguish a skill that works from a model that never needed it.
211
+ */
212
+ export function formatScorecard(summary, lift) {
130
213
  const { results } = summary;
131
214
  const g = results.effective_grade;
132
215
  const lines = [];
@@ -143,6 +226,16 @@ export function formatScorecard(summary) {
143
226
  const ship = g.ship ? "SHIP" : "NOT READY";
144
227
  const note = g.note ? ` (${g.note})` : "";
145
228
  lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
229
+ // Lift is a statement about a green run. On a red run the caller may still have
230
+ // a lift in hand (a green run exists in the same tag), but printing it under a
231
+ // baseline scorecard reads as if the baseline itself gained something.
232
+ if (lift && results.mode === "green") {
233
+ lines.push(` LIFT: ${liftHeadline(lift)} (vs red baseline ${lift.redTimestamp})`);
234
+ }
235
+ else if (results.mode === "green") {
236
+ // The grade alone can't answer "does this skill do anything?", so say how.
237
+ lines.push(` LIFT: no red baseline — run with --mode red to measure what the skill adds`);
238
+ }
146
239
  return lines.join("\n");
147
240
  }
148
241
  //# sourceMappingURL=run.js.map
@@ -0,0 +1,28 @@
1
+ /** Marker written into an `init` template's first comment. Its presence tells
2
+ * `suggest` the file is an unadopted template it may overwrite without --force. */
3
+ export declare const TEMPLATE_SENTINEL = "skill-harness: generated template";
4
+ /** Render a commented, empty-but-valid specification.yaml for a skill. */
5
+ export declare function renderTemplateSpec(skillName: string): string;
6
+ /** True if the text still carries the template sentinel (i.e. an unadopted template). */
7
+ export declare function isTemplateSpec(text: string): boolean;
8
+ export interface DraftScenario {
9
+ id: string;
10
+ title: string;
11
+ turns: string[];
12
+ checklist: string[];
13
+ }
14
+ export interface SuggestDraft {
15
+ judge_persona: string;
16
+ ship_bar: {
17
+ total: number;
18
+ min_pass: number;
19
+ no_critical_fail: boolean;
20
+ };
21
+ proposed_critical: string[];
22
+ scenarios: DraftScenario[];
23
+ }
24
+ /** Render a populated spec from an LLM draft. Strings are JSON-encoded (valid YAML
25
+ * flow scalars) so colons/quotes never break the file. Carries no sentinel. */
26
+ export declare function renderDraftSpec(skillName: string, draft: SuggestDraft): string;
27
+ export declare function buildSuggestPrompt(skillName: string, skillMd: string): string;
28
+ export declare function parseSuggestDraft(raw: string): SuggestDraft;
@@ -0,0 +1,188 @@
1
+ /** Marker written into an `init` template's first comment. Its presence tells
2
+ * `suggest` the file is an unadopted template it may overwrite without --force. */
3
+ export const TEMPLATE_SENTINEL = "skill-harness: generated template";
4
+ /** Render a commented, empty-but-valid specification.yaml for a skill. */
5
+ export function renderTemplateSpec(skillName) {
6
+ return `# ${TEMPLATE_SENTINEL} — \`suggest\` will overwrite this file while
7
+ # this line is present; delete it once you start editing by hand.
8
+ skill: ${skillName}
9
+
10
+ # How the LLM judge should role-play when grading transcripts.
11
+ judge_persona: >
12
+ a careful, fair reviewer.
13
+
14
+ # The ship bar: what it takes to SHIP.
15
+ # total = scenarios counted toward the bar
16
+ # min_pass = minimum passes required
17
+ # no_critical_fail = a critical-id fail blocks SHIP even if min_pass is met
18
+ ship_bar:
19
+ total: 1
20
+ min_pass: 1
21
+ no_critical_fail: true
22
+
23
+ # Scenario ids that block the ship if they fail (or set \`critical: true\` per scenario).
24
+ critical: []
25
+
26
+ scenarios:
27
+ # A* = baseline capability · B* = under-pressure / adversarial
28
+ - id: A1
29
+ title: describe what this scenario checks
30
+ # critical: true # uncomment to gate the ship on this scenario
31
+ turns:
32
+ - "the user's first message"
33
+ # - "a follow-up message for a multi-turn scenario"
34
+ checklist:
35
+ - "an observable thing the response must do"
36
+ `;
37
+ }
38
+ /** True if the text still carries the template sentinel (i.e. an unadopted template). */
39
+ export function isTemplateSpec(text) {
40
+ return text.includes(TEMPLATE_SENTINEL);
41
+ }
42
+ /** Render a populated spec from an LLM draft. Strings are JSON-encoded (valid YAML
43
+ * flow scalars) so colons/quotes never break the file. Carries no sentinel. */
44
+ export function renderDraftSpec(skillName, draft) {
45
+ const scenarioBlocks = draft.scenarios
46
+ .map((s) => {
47
+ const turns = s.turns.map((t) => ` - ${JSON.stringify(t)}`).join("\n");
48
+ const checks = s.checklist.map((c) => ` - ${JSON.stringify(c)}`).join("\n");
49
+ return ` - id: ${s.id}\n title: ${JSON.stringify(s.title)}\n turns:\n${turns}\n checklist:\n${checks}`;
50
+ })
51
+ .join("\n");
52
+ const proposed = draft.proposed_critical.length
53
+ ? `# proposed critical: [${draft.proposed_critical.join(", ")}] — move ids into \`critical: []\` below after review.`
54
+ : `# proposed critical: (none) — mark any ship-gating scenarios in \`critical: []\` below.`;
55
+ return `skill: ${skillName}
56
+
57
+ # REVIEW: does this judge persona fit the skill? Edit freely.
58
+ judge_persona: ${JSON.stringify(draft.judge_persona)}
59
+
60
+ # REVIEW: tune the ship bar before your first run.
61
+ ship_bar:
62
+ total: ${draft.ship_bar.total}
63
+ min_pass: ${draft.ship_bar.min_pass}
64
+ no_critical_fail: ${draft.ship_bar.no_critical_fail}
65
+
66
+ ${proposed}
67
+ critical: []
68
+
69
+ scenarios:
70
+ ${scenarioBlocks}
71
+ `;
72
+ }
73
+ export function buildSuggestPrompt(skillName, skillMd) {
74
+ return `You are drafting a test specification for an agent skill named "${skillName}".
75
+ Below is its SKILL.md. Propose scenarios that check whether an agent following this
76
+ skill behaves correctly, including at least one adversarial / under-pressure case.
77
+
78
+ Return ONLY a JSON object (no prose, no markdown fences) with exactly this shape:
79
+ {
80
+ "judge_persona": "<how a judge should role-play when grading transcripts>",
81
+ "ship_bar": { "total": <int>, "min_pass": <int>, "no_critical_fail": true },
82
+ "proposed_critical": ["<scenario id you think should gate the ship>", ...],
83
+ "scenarios": [
84
+ { "id": "A1", "title": "<short title>",
85
+ "turns": ["<the user's message>", "<optional follow-up turns>"],
86
+ "checklist": ["<an observable thing the response must do>", ...] }
87
+ ]
88
+ }
89
+ Use ids A1, A2, ... for baseline scenarios and B1, B2, ... for adversarial ones.
90
+ Every scenario needs at least one turn and one checklist item.
91
+
92
+ --- SKILL.md ---
93
+ ${skillMd}`;
94
+ }
95
+ /** Ids are interpolated raw into YAML (see renderDraftSpec); restrict the character
96
+ * set so a crafted id can never inject extra YAML keys (e.g. `critical: true`). */
97
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
98
+ function asStringArray(v, ctx) {
99
+ if (!Array.isArray(v) || v.length === 0 || v.some((x) => typeof x !== "string")) {
100
+ throw new Error(`${ctx} must be a non-empty array of strings`);
101
+ }
102
+ return v;
103
+ }
104
+ /** Extract the first complete top-level JSON object from a model reply, tolerating
105
+ * surrounding prose or ```json fences — including trailing text that itself
106
+ * contains braces (e.g. "…} Does {this} work?"). Scans brace depth while skipping
107
+ * string contents, so the object ends at its own matching `}`, not the last `}`
108
+ * anywhere in the reply. */
109
+ function extractJsonObject(raw) {
110
+ const start = raw.indexOf("{");
111
+ if (start < 0)
112
+ throw new Error("no JSON object in model output");
113
+ let depth = 0;
114
+ let inStr = false;
115
+ let escaped = false;
116
+ for (let i = start; i < raw.length; i++) {
117
+ const ch = raw[i];
118
+ if (inStr) {
119
+ if (escaped)
120
+ escaped = false;
121
+ else if (ch === "\\")
122
+ escaped = true;
123
+ else if (ch === '"')
124
+ inStr = false;
125
+ continue;
126
+ }
127
+ if (ch === '"')
128
+ inStr = true;
129
+ else if (ch === "{")
130
+ depth++;
131
+ else if (ch === "}" && --depth === 0)
132
+ return raw.slice(start, i + 1);
133
+ }
134
+ throw new Error("no complete JSON object in model output");
135
+ }
136
+ export function parseSuggestDraft(raw) {
137
+ let obj;
138
+ try {
139
+ obj = JSON.parse(extractJsonObject(raw));
140
+ }
141
+ catch (e) {
142
+ if (e instanceof Error && e.message.includes("JSON object in model output"))
143
+ throw e;
144
+ throw new Error(`model output is not valid JSON — ${e.message}`);
145
+ }
146
+ if (typeof obj.judge_persona !== "string" || !obj.judge_persona.trim()) {
147
+ throw new Error("judge_persona must be a non-empty string");
148
+ }
149
+ const sb = obj.ship_bar;
150
+ if (!sb || typeof sb.total !== "number" || typeof sb.min_pass !== "number") {
151
+ throw new Error("ship_bar must have numeric total and min_pass");
152
+ }
153
+ if (sb.min_pass > sb.total) {
154
+ throw new Error(`ship_bar.min_pass (${sb.min_pass}) cannot exceed total (${sb.total})`);
155
+ }
156
+ const proposed = Array.isArray(obj.proposed_critical)
157
+ ? obj.proposed_critical.filter((x) => typeof x === "string" && SAFE_ID.test(x))
158
+ : [];
159
+ if (!Array.isArray(obj.scenarios) || obj.scenarios.length === 0) {
160
+ throw new Error("scenarios must be a non-empty array");
161
+ }
162
+ const seen = new Set();
163
+ const scenarios = obj.scenarios.map((raw2, i) => {
164
+ const s = raw2;
165
+ if (typeof s.id !== "string" || !s.id.trim())
166
+ throw new Error(`scenario #${i + 1} needs a string id`);
167
+ if (!SAFE_ID.test(s.id))
168
+ throw new Error(`scenario id \`${s.id}\` must be alphanumeric (A-Z a-z 0-9 _ -)`);
169
+ if (seen.has(s.id))
170
+ throw new Error(`duplicate scenario id \`${s.id}\``);
171
+ seen.add(s.id);
172
+ if (typeof s.title !== "string" || !s.title.trim())
173
+ throw new Error(`scenario ${s.id} needs a title`);
174
+ return {
175
+ id: s.id,
176
+ title: s.title,
177
+ turns: asStringArray(s.turns, `scenario ${s.id} turns`),
178
+ checklist: asStringArray(s.checklist, `scenario ${s.id} checklist`),
179
+ };
180
+ });
181
+ return {
182
+ judge_persona: obj.judge_persona,
183
+ ship_bar: { total: sb.total, min_pass: sb.min_pass, no_critical_fail: sb.no_critical_fail !== false },
184
+ proposed_critical: proposed,
185
+ scenarios,
186
+ };
187
+ }
188
+ //# sourceMappingURL=scaffold.js.map
package/dist/score.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ShipBar } from "./spec.js";
2
- export type Verdict = "PASS" | "FAIL" | "ERROR";
2
+ /**
3
+ * "JUDGE-AMBIGUOUS": the judge emitted conflicting verdicts for one transcript. It is
4
+ * never a pass and never silently resolved — it marks the run for a rejudge.
5
+ */
6
+ export type Verdict = "PASS" | "FAIL" | "ERROR" | "JUDGE-AMBIGUOUS";
3
7
  export interface ScenarioVerdict {
4
8
  id: string;
5
9
  verdict: Verdict;