skill-probe 0.8.0 → 0.10.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/skills.js ADDED
@@ -0,0 +1,282 @@
1
+ /** Reading and writing SKILL.md frontmatter. One parser for every command — gen, diagnose, doctor,
2
+ * context and fix all used to grep `description:` with a single-line regex, which read a YAML block
3
+ * scalar (`description: >-` followed by indented lines) as the literal text ">-": doctor passed it,
4
+ * diagnose's forced choice was shown ">-" as the skill's description, and fix refused the skill.
5
+ *
6
+ * Scope: the YAML subset SKILL.md files use for top-level string keys — plain scalars (including
7
+ * indented continuation lines), single- and double-quoted scalars (possibly multi-line), and
8
+ * literal/folded block scalars with chomping indicators. Nested mappings are skipped. */
9
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ const KEY_RE = /^([A-Za-z_][\w-]*)\s*:(?:\s+(.*)|\s*)$/;
12
+ const BLOCK_RE = /^([|>])([+-]?)([1-9]?)([+-]?)\s*(?:#.*)?$/;
13
+ const indentOf = (l) => l.length - l.trimStart().length;
14
+ const isBlank = (l) => l.trim() === "";
15
+ /** Fold YAML flow lines: single newlines become spaces, blank lines become "\n". */
16
+ function foldLines(parts) {
17
+ let out = "";
18
+ let pendingBreaks = 0;
19
+ for (const p of parts) {
20
+ const t = p.trim();
21
+ if (!t) {
22
+ pendingBreaks++;
23
+ continue;
24
+ }
25
+ if (out)
26
+ out += pendingBreaks ? "\n".repeat(pendingBreaks) : " ";
27
+ out += t;
28
+ pendingBreaks = 0;
29
+ }
30
+ return out;
31
+ }
32
+ function unescapeDouble(s) {
33
+ const map = {
34
+ "0": "\0", a: "\x07", b: "\b", t: "\t", "\t": "\t", n: "\n", v: "\v", f: "\f", r: "\r",
35
+ e: "\x1b", " ": " ", '"': '"', "/": "/", "\\": "\\", N: "\x85", _: "\xa0",
36
+ };
37
+ return s.replace(/\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/g, (m, e) => {
38
+ if (e.length > 1)
39
+ return String.fromCodePoint(parseInt(e.slice(1), 16));
40
+ return map[e] ?? m;
41
+ });
42
+ }
43
+ /** Parse the value starting on line i (whose text after `key:` is `first`). Returns the value and
44
+ * the index one past its last line. */
45
+ function parseValue(lines, i, first, end) {
46
+ const block = BLOCK_RE.exec(first);
47
+ if (block) {
48
+ const style = block[1];
49
+ const chomp = block[2] || block[4] || "";
50
+ let j = i + 1;
51
+ let indent = block[3] ? Number(block[3]) : 0;
52
+ const body = [];
53
+ while (j < end) {
54
+ const l = lines[j];
55
+ if (isBlank(l)) {
56
+ body.push("");
57
+ j++;
58
+ continue;
59
+ }
60
+ const ind = indentOf(l);
61
+ if (!indent)
62
+ indent = ind;
63
+ if (ind < indent || ind === 0)
64
+ break;
65
+ body.push(l.slice(indent));
66
+ j++;
67
+ }
68
+ let trailing = 0;
69
+ while (body.length && body[body.length - 1] === "") {
70
+ body.pop();
71
+ trailing++;
72
+ }
73
+ let text;
74
+ if (style === "|")
75
+ text = body.join("\n");
76
+ else {
77
+ // folded: lines joined by spaces, blank lines kept as newlines, more-indented lines kept verbatim
78
+ text = "";
79
+ let prevMoreIndented = false;
80
+ for (let b = 0; b < body.length; b++) {
81
+ const l = body[b];
82
+ const moreIndented = /^\s/.test(l);
83
+ if (b === 0) {
84
+ text = l;
85
+ prevMoreIndented = moreIndented;
86
+ continue;
87
+ }
88
+ if (l === "") {
89
+ text += "\n";
90
+ continue;
91
+ }
92
+ const prev = body[b - 1];
93
+ text += prev === "" || moreIndented || prevMoreIndented ? (prev === "" ? "" : "\n") + l : " " + l;
94
+ prevMoreIndented = moreIndented;
95
+ }
96
+ }
97
+ if (chomp === "+")
98
+ text += "\n".repeat(trailing + 1);
99
+ else if (chomp !== "-" && text)
100
+ text += "\n";
101
+ // the block ends at the last non-blank line it consumed
102
+ let next = j;
103
+ while (next > i + 1 && isBlank(lines[next - 1]))
104
+ next--;
105
+ return { value: text, next };
106
+ }
107
+ if (first.startsWith('"') || first.startsWith("'")) {
108
+ const q = first[0];
109
+ const parts = [];
110
+ let rest = first.slice(1);
111
+ let j = i;
112
+ let terminated = false;
113
+ for (;;) {
114
+ // find the closing quote on this line
115
+ let closeAt = -1;
116
+ for (let c = 0; c < rest.length; c++) {
117
+ if (q === '"' && rest[c] === "\\") {
118
+ c++;
119
+ continue;
120
+ }
121
+ if (rest[c] === q) {
122
+ if (q === "'" && rest[c + 1] === "'") {
123
+ c++;
124
+ continue;
125
+ }
126
+ closeAt = c;
127
+ break;
128
+ }
129
+ }
130
+ if (closeAt >= 0) {
131
+ parts.push(rest.slice(0, closeAt));
132
+ terminated = true;
133
+ break;
134
+ }
135
+ parts.push(rest);
136
+ j++;
137
+ if (j >= end)
138
+ break; // unterminated — take what we have
139
+ rest = lines[j];
140
+ }
141
+ const folded = foldLines(parts.map((p, idx) => (idx === 0 ? p.trimEnd() : p)));
142
+ const value = q === '"' ? unescapeDouble(folded) : folded.replace(/''/g, "'");
143
+ return terminated ? { value, next: j + 1 } : { value, next: end, broken: true };
144
+ }
145
+ // plain scalar: this line plus any more-indented continuation lines
146
+ const parts = [first.replace(/\s+#.*$/, "")];
147
+ let j = i + 1;
148
+ while (j < end) {
149
+ const l = lines[j];
150
+ if (isBlank(l)) {
151
+ parts.push("");
152
+ j++;
153
+ continue;
154
+ }
155
+ if (indentOf(l) === 0)
156
+ break;
157
+ parts.push(l.replace(/\s+#.*$/, ""));
158
+ j++;
159
+ }
160
+ let next = j;
161
+ while (next > i + 1 && isBlank(lines[next - 1]))
162
+ next--;
163
+ return { value: foldLines(parts.slice(0, next - i)), next };
164
+ }
165
+ export function parseFrontmatter(text) {
166
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
167
+ const body = text.replace(/^\uFEFF/, "");
168
+ const lines = [];
169
+ const offsets = [];
170
+ const brk = /\r?\n/g;
171
+ let pos = 0;
172
+ for (let m = brk.exec(body); m; m = brk.exec(body)) {
173
+ offsets.push(pos);
174
+ lines.push(body.slice(pos, m.index));
175
+ pos = m.index + m[0].length;
176
+ }
177
+ offsets.push(pos, body.length);
178
+ lines.push(body.slice(pos));
179
+ const empty = { ok: false, fields: {}, spans: [], lines, close: -1, eol, offsets };
180
+ if (lines[0]?.trim() !== "---")
181
+ return empty;
182
+ let close = -1;
183
+ for (let i = 1; i < lines.length; i++) {
184
+ const t = lines[i].trimEnd();
185
+ if (t === "---" || t === "...") {
186
+ close = i;
187
+ break;
188
+ }
189
+ }
190
+ if (close < 0)
191
+ return empty;
192
+ const fields = {};
193
+ const spans = [];
194
+ let i = 1;
195
+ while (i < close) {
196
+ const line = lines[i];
197
+ const m = indentOf(line) === 0 ? KEY_RE.exec(line) : null;
198
+ if (!m) {
199
+ i++;
200
+ continue;
201
+ }
202
+ const { value, next, broken } = parseValue(lines, i, (m[2] ?? "").trim(), close);
203
+ const key = m[1];
204
+ if (!(key in fields))
205
+ fields[key] = value;
206
+ spans.push({ key, value, start: i, end: next, ...(broken ? { broken } : {}) });
207
+ i = Math.max(next, i + 1);
208
+ }
209
+ return { ok: true, fields, spans, lines, close, eol, offsets };
210
+ }
211
+ /** One-line form of a description, as a router would read it. */
212
+ export function normalizeDescription(s) {
213
+ return s.replace(/\s+/g, " ").trim();
214
+ }
215
+ /** Format a string as a YAML scalar for a single `key: value` line: plain when unambiguous,
216
+ * otherwise double-quoted (JSON string syntax is valid YAML). */
217
+ export function yamlScalar(s) {
218
+ const plainSafe = s.length > 0 && s === s.trim() && !/[\n\r\t]/.test(s) &&
219
+ !/^[-?:,[\]{}#&*!|>'"%@`]/.test(s) && !/:(\s|$)/.test(s) && !/\s#/.test(s) &&
220
+ !/^(true|false|yes|no|on|off|null|~|[-+]?(\d[\d_]*)?(\.\d+)?([eE][-+]?\d+)?|\.inf|\.nan)$/i.test(s);
221
+ return plainSafe ? s : JSON.stringify(s);
222
+ }
223
+ /** Return `text` with its frontmatter `description` replaced by `desc` (written on one line).
224
+ * Every other byte of the file — including each line's own line ending — is preserved. Throws if
225
+ * there is no frontmatter block or the existing description is an unterminated quoted string. */
226
+ export function replaceDescription(text, desc) {
227
+ const fm = parseFrontmatter(text);
228
+ if (!fm.ok)
229
+ throw new Error("SKILL.md has no `---` frontmatter block");
230
+ const bom = text.startsWith("\uFEFF") ? "\uFEFF" : "";
231
+ const body = text.slice(bom.length);
232
+ const line = `description: ${yamlScalar(normalizeDescription(desc))}`;
233
+ const lineEnd = (i) => fm.offsets[i] + fm.lines[i].length;
234
+ const span = fm.spans.find((s) => s.key === "description");
235
+ if (span?.broken) {
236
+ throw new Error("the SKILL.md description has an unterminated quote — fix the frontmatter first");
237
+ }
238
+ if (span) {
239
+ // swap the entry's text; the last replaced line keeps its own terminator
240
+ return bom + body.slice(0, fm.offsets[span.start]) + line + body.slice(lineEnd(span.end - 1));
241
+ }
242
+ const nameSpan = fm.spans.find((s) => s.key === "name");
243
+ const at = nameSpan ? nameSpan.end : fm.close;
244
+ const term = body.slice(lineEnd(at - 1), fm.offsets[at]) || fm.eol;
245
+ return bom + body.slice(0, fm.offsets[at]) + line + term + body.slice(fm.offsets[at]);
246
+ }
247
+ export function readSkillFile(file, folder) {
248
+ const fm = parseFrontmatter(readFileSync(file, "utf8"));
249
+ const desc = fm.fields["description"];
250
+ const fmName = fm.fields["name"];
251
+ return {
252
+ name: folder,
253
+ description: desc === undefined ? "" : normalizeDescription(desc),
254
+ ...(fmName !== undefined ? { frontmatterName: normalizeDescription(fmName) } : {}),
255
+ };
256
+ }
257
+ export function skillsRoot(cwd) {
258
+ return join(cwd, ".claude", "skills");
259
+ }
260
+ export function skillFile(cwd, name) {
261
+ return join(skillsRoot(cwd), name, "SKILL.md");
262
+ }
263
+ /** Every folder under <cwd>/.claude/skills/, following symlinks (a symlinked skill folder is a
264
+ * skill like any other), sorted. Throws if there is no skills dir. */
265
+ export function skillFolders(cwd) {
266
+ const root = skillsRoot(cwd);
267
+ if (!existsSync(root))
268
+ throw new Error(`no .claude/skills/ directory under ${cwd}`);
269
+ const isDir = (p) => { try {
270
+ return statSync(p).isDirectory();
271
+ }
272
+ catch {
273
+ return false;
274
+ } };
275
+ return readdirSync(root).filter((n) => isDir(join(root, n))).sort((a, b) => a.localeCompare(b));
276
+ }
277
+ /** Every skill folder under <cwd>/.claude/skills/ that has a SKILL.md, sorted by name. */
278
+ export function listSkills(cwd) {
279
+ return skillFolders(cwd)
280
+ .filter((n) => existsSync(skillFile(cwd, n)))
281
+ .map((n) => readSkillFile(skillFile(cwd, n), n));
282
+ }
package/dist/stats.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Statistical core (TS port of stats_ref/stats.py — the validated reference impl).
3
- * Skill activation is Bernoulli with unknown p; we estimate from k runs and never
4
- * report a bare point. See ../../STATS.md.
2
+ * Statistical core (TS port of the validated Python reference implementation from the research
3
+ * phase). Skill activation is Bernoulli with unknown p; we estimate from k runs and never report a
4
+ * bare point. The README's "How verdicts are decided" section explains the sequential verdicts.
5
5
  */
6
6
  export interface Interval {
7
7
  lo: number;
@@ -19,8 +19,30 @@ export interface Reliability {
19
19
  conf: number;
20
20
  }
21
21
  export declare function reliability(x: number, n: number, conf?: number): Reliability;
22
- /** Sequential stop rule: is p reliably above/below threshold yet, or keep sampling? */
23
- export declare function sequentialDecision(x: number, n: number, threshold: number, conf?: number): Decision;
22
+ export interface SequentialPlan {
23
+ k: number;
24
+ threshold: number;
25
+ /** per-direction error bound (1 - conf) */
26
+ alpha: number;
27
+ /** nominal one-sided level used at interim looks */
28
+ interimAlpha: number;
29
+ /** declare "above" at n valid probes when hits >= upper[n] (n+1 = can't) */
30
+ upper: number[];
31
+ /** declare "below" at n valid probes when hits <= lower[n] (-1 = can't) */
32
+ lower: number[];
33
+ /** exact P(declare above | p = threshold) and P(declare below | p = threshold); both <= alpha */
34
+ errAbove: number;
35
+ errBelow: number;
36
+ }
37
+ /** The verdict plan for up to k valid probes against `threshold` at confidence `conf`. Memoized. */
38
+ export declare function sequentialPlan(k: number, threshold: number, conf?: number): SequentialPlan;
39
+ /** Verdict after n valid probes with x hits. Valid at ANY n <= k: stopping early (decided) or
40
+ * because infra errors exhausted the attempt budget both keep the error guarantee. */
41
+ export declare function planDecision(plan: SequentialPlan, x: number, n: number): Decision;
42
+ /** Convenience: the verdict for x/n under the plan for max k (defaults to k = n, a fixed-n test). */
43
+ export declare function sequentialDecision(x: number, n: number, threshold: number, conf?: number, k?: number): Decision;
44
+ /** Smallest k at which an all-success run can certify reliability > threshold. */
45
+ export declare function kToCertify(threshold: number, conf?: number): number;
24
46
  /** Two-sided Fisher's exact test on [[a,b],[c,d]] — exact, for small samples. */
25
47
  export declare function fisherExact2x2(a: number, b: number, c: number, d: number): number;
26
48
  export interface ImprovementResult {
package/dist/stats.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Statistical core (TS port of stats_ref/stats.py — the validated reference impl).
3
- * Skill activation is Bernoulli with unknown p; we estimate from k runs and never
4
- * report a bare point. See ../../STATS.md.
2
+ * Statistical core (TS port of the validated Python reference implementation from the research
3
+ * phase). Skill activation is Bernoulli with unknown p; we estimate from k runs and never report a
4
+ * bare point. The README's "How verdicts are decided" section explains the sequential verdicts.
5
5
  */
6
6
  const Z = {
7
7
  "0.9": 1.6448536269514722,
@@ -26,15 +26,139 @@ export function reliability(x, n, conf = 0.95) {
26
26
  const { lo, hi } = wilsonInterval(x, n, conf);
27
27
  return { pHat: n ? x / n : 0, ciLow: lo, ciHigh: hi, k: n, conf };
28
28
  }
29
- /** Sequential stop rule: is p reliably above/below threshold yet, or keep sampling? */
30
- export function sequentialDecision(x, n, threshold, conf = 0.95) {
31
- const { lo, hi } = wilsonInterval(x, n, conf);
32
- if (lo > threshold)
29
+ // --- exact group-sequential verdicts ---
30
+ // Checking a CI after every probe and stopping as soon as it clears the threshold ("peeking")
31
+ // inflates the error rate far past the nominal level: measured on the old Wilson rule at k=35, a
32
+ // skill sitting exactly at the threshold was wrongly passed 9% and wrongly failed 18% of the time
33
+ // at a nominal 2.5% per side. A verdict here comes from a plan whose boundaries are calibrated by
34
+ // exact dynamic programming over every probe path, so the guarantee holds no matter when it stops:
35
+ //
36
+ // P(verdict "above" | true reliability <= threshold) <= 1 - conf
37
+ // P(verdict "below" | true reliability >= threshold) <= 1 - conf
38
+ //
39
+ // (one-sided per direction; the worst case is p == threshold because the boundaries are monotone).
40
+ // Design is Haybittle-Peto style: interim looks use a strict nominal level so only clear-cut cases
41
+ // stop early, and the final look spends whatever error budget is left.
42
+ /** interim looks never decide on fewer valid probes than this */
43
+ const MIN_LOOK = 3;
44
+ function binomPmfRow(n, p) {
45
+ const row = new Array(n + 1).fill(0);
46
+ if (p <= 0) {
47
+ row[0] = 1;
48
+ return row;
49
+ }
50
+ if (p >= 1) {
51
+ row[n] = 1;
52
+ return row;
53
+ }
54
+ const lp = Math.log(p), lq = Math.log(1 - p), ln = lgamma(n + 1);
55
+ for (let x = 0; x <= n; x++) {
56
+ row[x] = Math.exp(ln - lgamma(x + 1) - lgamma(n - x + 1) + x * lp + (n - x) * lq);
57
+ }
58
+ return row;
59
+ }
60
+ function buildPlan(k, theta, alpha, interim) {
61
+ const upper = Array.from({ length: k + 1 }, (_, n) => n + 1);
62
+ const lower = new Array(k + 1).fill(-1);
63
+ if (interim > 0) {
64
+ for (let n = MIN_LOOK; n < k; n++) {
65
+ const pmf = binomPmfRow(n, theta);
66
+ let acc = 0;
67
+ for (let x = n; x >= 0 && acc + pmf[x] <= interim; x--) {
68
+ acc += pmf[x];
69
+ upper[n] = x;
70
+ }
71
+ acc = 0;
72
+ for (let x = 0; x <= n && acc + pmf[x] <= interim; x++) {
73
+ acc += pmf[x];
74
+ lower[n] = x;
75
+ }
76
+ }
77
+ }
78
+ // dist[x] = P(X_n = x AND no boundary crossed yet | p = theta)
79
+ let dist = [1];
80
+ let errAbove = 0, errBelow = 0;
81
+ for (let n = 1; n <= k; n++) {
82
+ const next = new Array(n + 1).fill(0);
83
+ for (let x = 0; x < n; x++) {
84
+ next[x] += dist[x] * (1 - theta);
85
+ next[x + 1] += dist[x] * theta;
86
+ }
87
+ if (n < k) {
88
+ for (let x = 0; x <= n; x++) {
89
+ if (x >= upper[n]) {
90
+ errAbove += next[x];
91
+ next[x] = 0;
92
+ }
93
+ else if (x <= lower[n]) {
94
+ errBelow += next[x];
95
+ next[x] = 0;
96
+ }
97
+ }
98
+ }
99
+ dist = next;
100
+ }
101
+ // interim looks may use at most half the budget; the final look must keep real power
102
+ if (interim > 0 && (errAbove > alpha / 2 || errBelow > alpha / 2))
103
+ return null;
104
+ const tol = alpha * 1e-12;
105
+ for (let x = k; x >= 0 && errAbove + dist[x] <= alpha + tol; x--) {
106
+ errAbove += dist[x];
107
+ upper[k] = x;
108
+ }
109
+ for (let x = 0; x < upper[k] && errBelow + dist[x] <= alpha + tol; x++) {
110
+ errBelow += dist[x];
111
+ lower[k] = x;
112
+ }
113
+ return { k, threshold: theta, alpha, interimAlpha: interim, upper, lower, errAbove, errBelow };
114
+ }
115
+ const planCache = new Map();
116
+ /** The verdict plan for up to k valid probes against `threshold` at confidence `conf`. Memoized. */
117
+ export function sequentialPlan(k, threshold, conf = 0.95) {
118
+ if (!Number.isInteger(k) || k < 1)
119
+ throw new Error(`k must be a positive integer (got ${k})`);
120
+ if (!(threshold > 0 && threshold < 1))
121
+ throw new Error(`threshold must be in (0,1) (got ${threshold})`);
122
+ if (!(conf > 0.5 && conf < 1))
123
+ throw new Error(`conf must be in (0.5,1) (got ${conf})`);
124
+ const key = `${k}|${threshold}|${conf}`;
125
+ const hit = planCache.get(key);
126
+ if (hit)
127
+ return hit;
128
+ const alpha = 1 - conf;
129
+ let plan = null;
130
+ for (let interim = alpha / 10; !plan && interim > 1e-9; interim /= 2) {
131
+ plan = buildPlan(k, threshold, alpha, interim);
132
+ }
133
+ plan ??= buildPlan(k, threshold, alpha, 0); // no early stopping at all
134
+ planCache.set(key, plan);
135
+ return plan;
136
+ }
137
+ /** Verdict after n valid probes with x hits. Valid at ANY n <= k: stopping early (decided) or
138
+ * because infra errors exhausted the attempt budget both keep the error guarantee. */
139
+ export function planDecision(plan, x, n) {
140
+ if (n < 1)
141
+ return "undecided";
142
+ const i = Math.min(n, plan.k);
143
+ if (x >= plan.upper[i])
33
144
  return "above";
34
- if (hi < threshold)
145
+ if (x <= plan.lower[i])
35
146
  return "below";
36
147
  return "undecided";
37
148
  }
149
+ /** Convenience: the verdict for x/n under the plan for max k (defaults to k = n, a fixed-n test). */
150
+ export function sequentialDecision(x, n, threshold, conf = 0.95, k = n) {
151
+ return planDecision(sequentialPlan(Math.max(k, 1), threshold, conf), x, n);
152
+ }
153
+ /** Smallest k at which an all-success run can certify reliability > threshold. */
154
+ export function kToCertify(threshold, conf = 0.95) {
155
+ const est = Math.ceil(Math.log(1 - conf) / Math.log(threshold));
156
+ for (let k = Math.max(1, est - 3); k <= Math.max(est + 50, 10); k++) {
157
+ if (sequentialPlan(k, threshold, conf).upper[k] <= k)
158
+ return k;
159
+ }
160
+ return est;
161
+ }
38
162
  // --- lgamma (Lanczos) ---
39
163
  const LANCZOS = [
40
164
  0.99999999999980993, 676.5203681218851, -1259.1392167224028,
package/dist/types.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  /** Core types. The normalized ProbeResult is the seam every runtime adapter maps to. */
2
+ /** What the runtime reported about its own session — the ground truth of what was co-loaded. */
3
+ export interface ProbeEnv {
4
+ /** every skill the runtime loaded for this probe (library + runtime built-ins + anything else) */
5
+ loadedSkills?: string[];
6
+ /** runtime CLI version, e.g. "2.1.305" */
7
+ runtimeVersion?: string;
8
+ /** model the runtime actually used (resolves "(runtime default)") */
9
+ model?: string;
10
+ }
2
11
  export interface ProbeResult {
3
12
  /** "ok" = the runtime ran and we trust the reading; "error" = infrastructure failure
4
13
  * (timeout, auth, crash, empty/unparseable output) — must NOT count as a behavioral outcome. */
@@ -11,6 +20,8 @@ export interface ProbeResult {
11
20
  cost: number;
12
21
  /** failure reason when status==="error" */
13
22
  error?: string;
23
+ /** session facts reported by the runtime, when it exposes them */
24
+ env?: ProbeEnv;
14
25
  }
15
26
  export interface ProbeOpts {
16
27
  cwd: string;
@@ -18,9 +29,18 @@ export interface ProbeOpts {
18
29
  /** model id to pin (runtime-specific format, e.g. "claude-sonnet-4-6" or
19
30
  * "anthropic/claude-haiku-4-5"); omitted = the runtime's default. */
20
31
  model?: string;
32
+ /** agent turns the runtime may take before we read the outcome (default 1) */
33
+ maxTurns?: number;
34
+ /** also load the user's personal runtime config (user settings, plugins, MCP servers). Off by
35
+ * default: personal config differs per machine, so results wouldn't reproduce in CI. */
36
+ userConfig?: boolean;
21
37
  }
22
38
  export interface RuntimeAdapter {
23
39
  readonly name: string;
40
+ /** honours ProbeOpts.userConfig=false by keeping the user's personal config out of probes */
41
+ readonly isolatesUserConfig?: boolean;
42
+ /** honours ProbeOpts.maxTurns */
43
+ readonly supportsMaxTurns?: boolean;
24
44
  /** Run one prompt with the library at cwd co-loaded; report what fired. */
25
45
  probe(prompt: string, opts: ProbeOpts): Promise<ProbeResult>;
26
46
  }
@@ -39,7 +59,17 @@ export interface Config {
39
59
  k: number;
40
60
  /** minimum acceptable trigger reliability (0..1) */
41
61
  threshold: number;
42
- /** confidence level for intervals */
62
+ /** confidence level for intervals and verdicts */
43
63
  conf: number;
44
64
  cases: EvalCase[];
65
+ /** agent turns per probe (default 1) */
66
+ maxTurns?: number;
67
+ /** load the user's personal runtime config into probes (default false — hermetic) */
68
+ userConfig?: boolean;
69
+ /** per-probe timeout in ms (default 120000) */
70
+ timeoutMs?: number;
71
+ /** fail a case when ANY other skill fires, even if reliability clears the threshold (default false) */
72
+ failOnTheft?: boolean;
45
73
  }
74
+ /** The probe settings a Config implies (shared by every command). */
75
+ export declare function probeSettings(cfg: Config): Omit<ProbeOpts, "cwd">;
package/dist/types.js CHANGED
@@ -1,2 +1,10 @@
1
1
  /** Core types. The normalized ProbeResult is the seam every runtime adapter maps to. */
2
- export {};
2
+ /** The probe settings a Config implies (shared by every command). */
3
+ export function probeSettings(cfg) {
4
+ return {
5
+ ...(cfg.model ? { model: cfg.model } : {}),
6
+ ...(cfg.maxTurns !== undefined ? { maxTurns: cfg.maxTurns } : {}),
7
+ ...(cfg.userConfig ? { userConfig: true } : {}),
8
+ ...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
9
+ };
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-probe",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Runtime-agnostic, co-loaded-aware auditor for AI agent skill libraries: measures which skill actually fires, with statistical confidence.",
5
5
  "type": "module",
6
6
  "bin": {