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/README.md +173 -38
- package/assets/skill-probe-thief-demo.gif +0 -0
- package/assets/skill-probe-thief-demo.png +0 -0
- package/dist/adapters/claudeCode.d.ts +17 -1
- package/dist/adapters/claudeCode.js +49 -45
- package/dist/adapters/openCode.d.ts +14 -1
- package/dist/adapters/openCode.js +64 -47
- package/dist/baseline.d.ts +56 -0
- package/dist/baseline.js +121 -0
- package/dist/cli.js +99 -37
- package/dist/config.d.ts +4 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +32 -9
- package/dist/context.js +124 -95
- package/dist/diagnose.d.ts +14 -5
- package/dist/diagnose.js +45 -24
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +72 -38
- package/dist/eval.d.ts +20 -5
- package/dist/eval.js +56 -21
- package/dist/fix.d.ts +9 -6
- package/dist/fix.js +82 -70
- package/dist/gen.d.ts +6 -5
- package/dist/gen.js +1 -21
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +23 -23
- package/dist/manifest.d.ts +40 -0
- package/dist/manifest.js +73 -0
- package/dist/orchestrator.d.ts +47 -5
- package/dist/orchestrator.js +82 -13
- package/dist/proc.d.ts +24 -0
- package/dist/proc.js +101 -0
- package/dist/report.d.ts +7 -2
- package/dist/report.js +121 -24
- package/dist/sandbox.d.ts +18 -0
- package/dist/sandbox.js +98 -0
- package/dist/skills.d.ts +48 -0
- package/dist/skills.js +282 -0
- package/dist/stats.d.ts +27 -5
- package/dist/stats.js +132 -8
- package/dist/types.d.ts +31 -1
- package/dist/types.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# skill-probe
|
|
2
2
|
|
|
3
|
+
[](https://github.com/HystonKayange/skill-probe/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/skill-probe)
|
|
5
|
+
|
|
3
6
|
Runtime-agnostic, **co-loaded-aware** auditor for an AI agent's skill library. It measures
|
|
4
7
|
which skill *actually* fires (by real activation, not keyword matching) when your whole
|
|
5
8
|
`SKILL.md` library is loaded together — with **statistical confidence**, not single-shot guesses.
|
|
6
9
|
|
|
7
|
-

|
|
8
11
|
|
|
9
12
|
> **Your skill works alone. Does it still work when the whole library is loaded?**
|
|
10
13
|
|
|
@@ -18,6 +21,14 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
|
|
|
18
21
|
> authenticated: **`claude`** (logged in) for `runtime: claude-code`, or **`opencode`** for
|
|
19
22
|
> `runtime: opencode`. `gen`/`fix`/`diagnose` use `ANTHROPIC_API_KEY` if set, otherwise the local `claude` CLI.
|
|
20
23
|
> Codex/Gemini runtimes aren't supported yet (their traces don't expose which skill fired).
|
|
24
|
+
>
|
|
25
|
+
> **Claude Code probes are hermetic by default.** Your personal Claude Code config — user plugins, synced skills,
|
|
26
|
+
> user settings, MCP servers — is kept out of every probe, so a run on your laptop measures the same
|
|
27
|
+
> thing as a run in CI (measured: a 2-skill project loaded 32 skills with personal config, 21
|
|
28
|
+
> without). Claude Code's own built-in skills ship with the runtime, so they stay — and every report
|
|
29
|
+
> lists them, because they compete for triggers too. Pass `--user-config` to measure your personal
|
|
30
|
+
> environment instead. (`opencode run` always loads its global config; reports on OpenCode runs say
|
|
31
|
+
> so rather than claiming isolation.)
|
|
21
32
|
|
|
22
33
|
The workflow:
|
|
23
34
|
|
|
@@ -27,7 +38,8 @@ doctor → gen → audit → context → diagnose → fix
|
|
|
27
38
|
|
|
28
39
|
- `doctor`: verify setup, auth, runtime, skills, and config before spending probes.
|
|
29
40
|
- `gen`: draft a probe config from your existing skills.
|
|
30
|
-
- `audit`: measure which skill actually fires when the whole library is co-loaded
|
|
41
|
+
- `audit`: measure which skill actually fires when the whole library is co-loaded — with
|
|
42
|
+
`--baseline`, gate CI on statistically significant regressions only.
|
|
31
43
|
- `context`: compare isolation vs co-loaded activation to catch library interference —
|
|
32
44
|
add `--ablate` to name **which** sibling is stealing the trigger.
|
|
33
45
|
- `diagnose`: explain whether a failure is a routing miss or a description problem.
|
|
@@ -37,8 +49,10 @@ doctor → gen → audit → context → diagnose → fix
|
|
|
37
49
|
|
|
38
50
|
- Skill activation is stochastic: the same prompt can produce different skill-routing outcomes.
|
|
39
51
|
- A single run lies (we measured the same prompt at 0/5 one batch, 2/3 the next).
|
|
40
|
-
- skill-probe runs each prompt **k times**, reports a **Wilson 95% CI**, stops early when the
|
|
41
|
-
|
|
52
|
+
- skill-probe runs each prompt up to **k times**, reports a **Wilson 95% CI**, stops early when the
|
|
53
|
+
verdict is statistically decided — with an **exact error guarantee** that holds however early it
|
|
54
|
+
stops (see [How verdicts are decided](#how-verdicts-are-decided)) — and flags **trigger-theft** (a
|
|
55
|
+
sibling stealing a trigger).
|
|
42
56
|
|
|
43
57
|
## Install
|
|
44
58
|
|
|
@@ -64,16 +78,21 @@ PASS config parsed: probe.config.json
|
|
|
64
78
|
PASS found .claude/skills/ with 4 skills
|
|
65
79
|
PASS expected skills all exist
|
|
66
80
|
PASS claude CLI found
|
|
67
|
-
PASS claude-code probe succeeded (CLI is authenticated)
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
PASS claude-code probe succeeded (CLI is authenticated) — claude 2.1.305, default model claude-opus-5-5
|
|
82
|
+
PASS the runtime loads all 4 library skills
|
|
83
|
+
WARN skill "debug" has the same name as a claude-code built-in skill — the two compete for the same trigger
|
|
84
|
+
INFO 19 claude-code built-in skill(s) load alongside your library in every probe (deep-research, …)
|
|
85
|
+
WARN threshold 90% with k=10 cannot certify a pass even on a perfect run — need k≥29
|
|
86
|
+
FAIL skill "greeter" missing description: (in a --- frontmatter block)
|
|
70
87
|
```
|
|
71
88
|
|
|
72
89
|
It checks: Node version, `.claude/skills/` exists, every skill has a `SKILL.md` with `name:` +
|
|
73
|
-
`description
|
|
74
|
-
threshold/k are statistically achievable, the runtime CLI is installed,
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
`description:` (single-line or multi-line YAML), names match folders, the config parses, every
|
|
91
|
+
`expected` skill exists, the threshold/k are statistically achievable, the runtime CLI is installed,
|
|
92
|
+
a harmless live probe authenticates, **the runtime actually loaded every library skill** (a
|
|
93
|
+
`SKILL.md` it rejects can never fire), and no library skill shares a name with a runtime built-in.
|
|
94
|
+
Exit `0` healthy · `1` warnings only · `2` hard failures. Use `--skip-probe` to skip the two live
|
|
95
|
+
(costing) probes.
|
|
77
96
|
|
|
78
97
|
## Generate a config (`skill-probe gen`)
|
|
79
98
|
|
|
@@ -119,6 +138,7 @@ Config (`skill-probe.config.json`):
|
|
|
119
138
|
Output:
|
|
120
139
|
```
|
|
121
140
|
skill-probe — runtime: claude-code model: (runtime default) threshold: 70%
|
|
141
|
+
runtime 2.1.305 · model resolved to claude-opus-5-5 · 19 skill(s) outside the library also loaded (batch, claude-api, code-review, …) · hermetic (personal runtime config excluded)
|
|
122
142
|
|
|
123
143
|
PASS expect=commit-writer | write a commit message
|
|
124
144
|
reliability 100% [72%, 100%] k=10
|
|
@@ -126,7 +146,7 @@ skill-probe — runtime: claude-code model: (runtime default) threshold: 70%
|
|
|
126
146
|
FAIL expect=pr-describer | write a pull request description
|
|
127
147
|
reliability 20% [6%, 51%] k=10
|
|
128
148
|
outcomes: None×8, commit-writer×2
|
|
129
|
-
⚠ trigger-theft by: commit-writer
|
|
149
|
+
⚠ trigger-theft by: commit-writer 2/10
|
|
130
150
|
PASS expect=(none) | what's the weather?
|
|
131
151
|
reliability 100% [72%, 100%] k=10
|
|
132
152
|
outcomes: None×10
|
|
@@ -137,36 +157,125 @@ Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
|
|
|
137
157
|
- `cwd` (relative paths resolve against the **config file's** directory) is a project dir
|
|
138
158
|
containing `.claude/skills/`.
|
|
139
159
|
- `expected: null` = a decoy that should fire nothing.
|
|
140
|
-
- Exit code: `0` all pass, `1` a behavioral fail
|
|
141
|
-
|
|
160
|
+
- Exit code: `0` all pass, `1` a behavioral fail, `2` inconclusive or an infrastructure error
|
|
161
|
+
(runtime down → never a silent pass).
|
|
162
|
+
- **Trigger-theft** (another skill fired) is a warning with counts. On its own, one stray firing out
|
|
163
|
+
of k isn't evidence of a problem — the verdict comes from the reliability test. Add
|
|
164
|
+
`--fail-on-theft` (or `"failOnTheft": true`) for zero tolerance.
|
|
165
|
+
- Unknown config keys are an error (a typo'd `"treshold"` would otherwise silently use the default).
|
|
142
166
|
|
|
143
167
|
**Output options:** a live `probing [2/3] …` progress line prints to stderr while it runs (use
|
|
144
168
|
`--quiet` to silence it). `--markdown` emits a table you can paste into a PR/README; `--json`
|
|
145
169
|
for machine output; `--no-cost` hides the cost line (handy on a Claude subscription, where the
|
|
146
170
|
dollar figure is just an estimate, not a charge).
|
|
147
171
|
|
|
172
|
+
**Run options** (every probing command):
|
|
173
|
+
|
|
174
|
+
| Flag | Config key | What it does |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `--concurrency <n>` | — | probe n cases in parallel (default 1 — mind your rate limits) |
|
|
177
|
+
| `--max-cost <usd>` | — | stop runtime probes once they've spent this much; cases cut short are `inconclusive` (`error` if never started), never `pass`. LLM calls (`gen`, `fix`'s rewrite, `diagnose`'s forced choice) aren't counted |
|
|
178
|
+
| `--max-turns <n>` | `maxTurns` | agent turns per probe (default 1). If the report says probes "called another tool first", try 2 |
|
|
179
|
+
| `--timeout <sec>` | `timeout` | per-probe timeout (default 120) |
|
|
180
|
+
| `--user-config` | `userConfig` | load your personal runtime config into probes (not reproducible in CI) |
|
|
181
|
+
|
|
182
|
+
Interrupting a run (Ctrl-C) kills in-flight probes and removes every sandbox it created.
|
|
183
|
+
|
|
148
184
|
**Cross-runtime comparison must pin the model.** A skill can fire on one runtime and not another
|
|
149
185
|
partly because of the *model*, not the runtime. Always set `model` (recorded in the report) so a
|
|
150
186
|
Claude Code vs OpenCode comparison is fair — otherwise you're comparing two confounded variables.
|
|
151
187
|
|
|
152
|
-
**Two modes** — because
|
|
153
|
-
- **Smoke (default):** `threshold 0.7, k 10` — a clean
|
|
154
|
-
- **Certify:** a strict bar needs more runs. To certify **≥0.9**
|
|
155
|
-
case is inconclusive the report prints the
|
|
156
|
-
0.9, k 10` and expect a pass — that's statistically impossible and
|
|
188
|
+
**Two modes** — because small samples can't certify much:
|
|
189
|
+
- **Smoke (default):** `threshold 0.7, k 10` — a clean 10/10 certifies; cheap; good for CI.
|
|
190
|
+
- **Certify:** a strict bar needs more runs. To certify **≥0.9** a perfect run needs **k≥29** (more
|
|
191
|
+
if some probes miss); when a case is inconclusive the report prints the k for your threshold.
|
|
192
|
+
Don't set `threshold 0.9, k 10` and expect a pass — that's statistically impossible, and
|
|
193
|
+
`doctor` says so before you spend anything.
|
|
194
|
+
|
|
195
|
+
### How verdicts are decided
|
|
196
|
+
|
|
197
|
+
A naive "stop as soon as the confidence interval clears the threshold" rule peeks after every probe,
|
|
198
|
+
and each peek is another chance to be wrong. Measured on that rule at k=35, a skill sitting exactly
|
|
199
|
+
at the threshold was wrongly passed **9%** and wrongly failed **18%** of the time — against a nominal
|
|
200
|
+
2.5%. skill-probe instead decides with an **exact group-sequential test** (Haybittle-Peto style:
|
|
201
|
+
strict interim looks so only clear-cut cases stop early, the remaining error budget spent at the
|
|
202
|
+
final look), calibrated by enumerating every possible probe path. The guarantee, for any k:
|
|
203
|
+
|
|
204
|
+
- **PASS** — if the skill's true reliability is at or below the threshold, the chance of this verdict is ≤ `1 − conf`
|
|
205
|
+
- **FAIL** — if it's at or above the threshold, the chance of this verdict is ≤ `1 − conf`
|
|
206
|
+
|
|
207
|
+
Stopping early, or ending short because of infrastructure errors, keeps the guarantee. The test suite
|
|
208
|
+
re-derives these error rates by brute-force enumeration over a grid of k, thresholds and confidence
|
|
209
|
+
levels — and shows the old rule failing the same check. The printed interval is the usual two-sided
|
|
210
|
+
Wilson CI, for reading; the verdict comes from the test.
|
|
157
211
|
|
|
158
212
|
## CI usage
|
|
159
213
|
|
|
160
|
-
skill-probe exits non-zero on a real problem, so it drops straight into a pipeline:
|
|
214
|
+
skill-probe exits non-zero on a real problem, so it drops straight into a pipeline. Exit codes:
|
|
215
|
+
**`0`** all pass · **`1`** a behavioral fail / interference / regression (trigger-theft only with `--fail-on-theft`) · **`2`**
|
|
216
|
+
inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
|
|
217
|
+
passing. Add `--json` to archive the full result (it includes a **run manifest**: tool version,
|
|
218
|
+
model, date, config hash — so two runs are comparable and a report is citable).
|
|
219
|
+
|
|
220
|
+
### Regression gating (`--baseline`)
|
|
221
|
+
|
|
222
|
+
The CI question isn't "are my skills perfect?" — it's **"did this PR make any skill worse?"**
|
|
223
|
+
Activation is stochastic, so comparing raw rates makes CI flaky. The baseline gate compares each
|
|
224
|
+
case against a saved baseline with **Fisher's exact test, Benjamini-Hochberg corrected** — noise
|
|
225
|
+
passes, real drops fail:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
# once, on a good main build (commit the file):
|
|
229
|
+
skill-probe --config probe.config.json --save-baseline baselines/main.json
|
|
230
|
+
|
|
231
|
+
# on every PR:
|
|
232
|
+
skill-probe --config probe.config.json --baseline baselines/main.json
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
Baseline gate — vs baseline saved 2026-07-02T09:12:03Z (skill-probe 0.9.0)
|
|
237
|
+
[▼ REGRESSED] greeter | write a birthday greeting
|
|
238
|
+
90% (9/10) → 20% (2/10) Δ-70% p=0.005 · adj 0.005
|
|
239
|
+
Gate: ▼ FAIL — 1 significant regression(s)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The gate warns (but still runs, matching cases by prompt) if the config, runtime, or model changed
|
|
243
|
+
since the baseline — apples-to-oranges comparisons are flagged, never silent. Significant
|
|
244
|
+
*improvements* are reported too, as a nudge to re-save the baseline.
|
|
245
|
+
|
|
246
|
+
### GitHub Action
|
|
161
247
|
|
|
162
248
|
```yaml
|
|
163
|
-
# .github/workflows/skills.yml
|
|
164
|
-
|
|
249
|
+
# .github/workflows/skills.yml
|
|
250
|
+
name: skills
|
|
251
|
+
on: pull_request
|
|
252
|
+
jobs:
|
|
253
|
+
probe:
|
|
254
|
+
runs-on: ubuntu-latest
|
|
255
|
+
steps:
|
|
256
|
+
- uses: actions/checkout@v4
|
|
257
|
+
- uses: actions/setup-node@v4
|
|
258
|
+
with: { node-version: "22" }
|
|
259
|
+
- uses: HystonKayange/skill-probe@main
|
|
260
|
+
with:
|
|
261
|
+
config: probe.config.json
|
|
262
|
+
args: --baseline baselines/main.json
|
|
263
|
+
env:
|
|
264
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
165
265
|
```
|
|
166
266
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
267
|
+
The action installs skill-probe plus the runtime CLI (Claude Code by default; set
|
|
268
|
+
`runtime-package: ""` if your runner already has one) and runs the command. `command:` selects
|
|
269
|
+
`audit` (default), `context`, `diagnose`, or `doctor`; `args:` passes extra flags like `--ablate`
|
|
270
|
+
or `--max-cost 2`; `version:` pins skill-probe (recommended, so a gate doesn't change under you);
|
|
271
|
+
the report is also written to the job summary (`summary: "false"` to turn that off). Inputs reach
|
|
272
|
+
the shell only through environment variables, so feeding them from PR data can't inject commands.
|
|
273
|
+
The runtime authenticates via `ANTHROPIC_API_KEY` — headless probes each cost real tokens, so
|
|
274
|
+
smoke settings (`k: 10`, few cases, a `--max-cost` cap) are the sweet spot for per-PR gating.
|
|
275
|
+
|
|
276
|
+
The baseline records what the runtime actually ran with — its version, the model it resolved to,
|
|
277
|
+
and the skills it loaded besides yours — and the gate warns when any of those drifted (a Claude Code
|
|
278
|
+
upgrade can add a built-in skill that steals a trigger; that's worth knowing before reading Δ).
|
|
170
279
|
|
|
171
280
|
## Activation rate by context (`skill-probe context`)
|
|
172
281
|
|
|
@@ -199,6 +308,14 @@ skill-probe context — runtime: claude-code model: (runtime default) threshol
|
|
|
199
308
|
Result: 1 interference / 3 measured | exit 1
|
|
200
309
|
```
|
|
201
310
|
|
|
311
|
+
Every condition — isolation, co-loaded, and each `--ablate` run — executes in an **identical
|
|
312
|
+
sandbox**: the skill subset plus your project's `CLAUDE.md` / `AGENTS.md` / `.claude/CLAUDE.md`, so
|
|
313
|
+
the *only* difference between conditions is which skills are present. (Comparing a sandbox against
|
|
314
|
+
your real project dir would blame the library for anything your `CLAUDE.md` does — and every
|
|
315
|
+
ablation would "recover" and name an innocent sibling.) When the runtime reports what it loaded,
|
|
316
|
+
each condition is checked against what it should contain; a mismatch makes that comparison an error,
|
|
317
|
+
not a result.
|
|
318
|
+
|
|
202
319
|
This is the "fires in isolation, fails under load X" case the aggregate rate hides. Decoy cases
|
|
203
320
|
(`expected: null`) are skipped — there's no single skill to isolate; an `expected` skill that isn't
|
|
204
321
|
in the library is a config error (typo), surfaced before any probes run. Exit `1` if any skill
|
|
@@ -233,9 +350,11 @@ Result: 1 interference / 1 measured | thieves named: welcomer | exit 1
|
|
|
233
350
|
|
|
234
351
|
(That's a real run.) Honest edge cases: if the co-loaded probes were suppressed to **None** —
|
|
235
352
|
nothing stole them, the skill just stops firing under load — there's no suspect to remove, and the
|
|
236
|
-
report says *"dilution, not theft; nothing to ablate"* instead of guessing. If
|
|
237
|
-
|
|
238
|
-
|
|
353
|
+
report says *"dilution, not theft; nothing to ablate"* instead of guessing. If the thief is
|
|
354
|
+
**outside your library** — e.g. a Claude Code built-in like `code-review` — it can't be removed, and
|
|
355
|
+
the report says so and tells you to narrow or rename your skill. If no single removal recovers
|
|
356
|
+
activation, it says so ("interference may be combinatorial") rather than naming an innocent
|
|
357
|
+
sibling.
|
|
239
358
|
|
|
240
359
|
## Why a skill fails (`skill-probe diagnose`)
|
|
241
360
|
|
|
@@ -288,11 +407,15 @@ ANTHROPIC_API_KEY=sk-ant-... \
|
|
|
288
407
|
```
|
|
289
408
|
|
|
290
409
|
It (1) LLM-rewrites the description, told the sibling skills so it won't steal their triggers;
|
|
291
|
-
(2) runs
|
|
292
|
-
|
|
293
|
-
(
|
|
294
|
-
|
|
295
|
-
`SKILL.md
|
|
410
|
+
(2) runs a **paired** before/after in two identical sandboxes of your library — one with the old
|
|
411
|
+
description, one with the new — probing both at the same moment so drift hits both arms equally;
|
|
412
|
+
(3) computes the **Bayesian** P(improvement) + a credible interval on the change; and (4) **writes
|
|
413
|
+
the rewrite only if** `P(improvement) ≥ --apply-threshold` (default 0.9) **and** the effect is
|
|
414
|
+
positive. Your real `SKILL.md` is never touched during the experiment — an interrupted run can't
|
|
415
|
+
leave it half-edited — and if it changed on disk while `fix` ran, `fix` refuses to overwrite it.
|
|
416
|
+
When applied, the original is snapshotted to a timestamped `SKILL.md.bak.<timestamp>` first.
|
|
417
|
+
Multi-line (`>-`/`|`) descriptions are read and rewritten correctly; the rest of the file is
|
|
418
|
+
preserved byte for byte.
|
|
296
419
|
|
|
297
420
|
```
|
|
298
421
|
before: 0% [0%, 49%] after: 100% [51%, 100%] (4 paired runs)
|
|
@@ -316,11 +439,17 @@ probe.
|
|
|
316
439
|
**Gen (`skill-probe gen`):** drafts a reviewable probe config from existing skills, including
|
|
317
440
|
should-fire prompts, near-misses, and decoys.
|
|
318
441
|
|
|
319
|
-
**Audit (`skill-probe`):** Wilson confidence intervals + sequential
|
|
320
|
-
|
|
442
|
+
**Audit (`skill-probe`):** Wilson confidence intervals + exact group-sequential verdicts (error
|
|
443
|
+
bounded by `1 − conf` however early a run stops) + four-state verdict (pass / fail / inconclusive /
|
|
444
|
+
**error**), across two runtimes (Claude Code, OpenCode). Hermetic Claude Code probes; the runtime's own report
|
|
445
|
+
of what it loaded is recorded and checked.
|
|
321
446
|
Infrastructure failures (timeout / auth / crash / empty output / a zero-cost response where the
|
|
322
447
|
model never actually ran, e.g. a usage-limit window) are reported as `error`, never as a
|
|
323
|
-
behavioral pass/fail — a decoy can't falsely pass because the runtime was down
|
|
448
|
+
behavioral pass/fail — a decoy can't falsely pass because the runtime was down (OpenCode included:
|
|
449
|
+
an error event is an error, not "no skill fired"). Every run carries a **manifest** (version, model,
|
|
450
|
+
date, config hash, and the environment the runtime reported); `--save-baseline` / `--baseline` turn audits
|
|
451
|
+
into a CI regression gate (Fisher + BH — noise passes, real drops fail), and a **GitHub Action**
|
|
452
|
+
(`HystonKayange/skill-probe@main`) wraps the whole thing for per-PR gating.
|
|
324
453
|
|
|
325
454
|
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
326
455
|
test** on the drop and **Benjamini-Hochberg correction** across cases — catches skills that fire
|
|
@@ -335,8 +464,9 @@ is a heuristic, not ground truth, and the README calls that out explicitly.
|
|
|
335
464
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
336
465
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
337
466
|
|
|
338
|
-
Every statistical function in `src/stats.ts` is
|
|
339
|
-
|
|
467
|
+
Every statistical function in `src/stats.ts` is wired into a command: Wilson intervals + the exact
|
|
468
|
+
sequential plan (audit/context/diagnose), Fisher's exact + Benjamini-Hochberg (context/ablate,
|
|
469
|
+
baseline), Bayesian Beta-Binomial (fix).
|
|
340
470
|
|
|
341
471
|
## Dev
|
|
342
472
|
|
|
@@ -344,4 +474,9 @@ stopping (audit), Fisher's exact + Benjamini-Hochberg (context/ablate), Bayesian
|
|
|
344
474
|
node --test test/*.test.ts # run tests (zero deps; Node >= 22.18 strips types)
|
|
345
475
|
node src/cli.ts --help
|
|
346
476
|
npm run typecheck # tsc --noEmit (needs `npm i`)
|
|
477
|
+
npm run build # compile to dist/ (what the npm package runs)
|
|
347
478
|
```
|
|
479
|
+
|
|
480
|
+
`test/fixtures/` holds real, sanitized runtime output (Claude Code stream-json, an OpenCode error
|
|
481
|
+
event) so the parsers are pinned to what the CLIs actually emit. The `live-smoke` workflow runs real
|
|
482
|
+
probes weekly (needs the `ANTHROPIC_API_KEY` secret) to catch runtime CLI changes the fixtures can't.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
/** Claude Code adapter — the proven probe. Spawns `claude -p` headless, parses stream-json
|
|
2
|
+
* for `tool_use name=="Skill"`. Distinguishes a real behavioral outcome (the model ran) from
|
|
3
|
+
* an infrastructure failure (timeout / auth / crash / empty output) — finding #1.
|
|
4
|
+
*
|
|
5
|
+
* Hermetic by default: `--setting-sources project --strict-mcp-config` keeps the user's personal
|
|
6
|
+
* plugins, synced skills, settings and MCP servers out of the probe (measured live on 2.1.305: a
|
|
7
|
+
* 2-skill project loaded 32 skills with user config, 21 without — the rest are Claude Code's own
|
|
8
|
+
* built-ins, which ship with the runtime and are recorded, not removed). `--no-session-persistence`
|
|
9
|
+
* stops every probe from writing a session transcript into ~/.claude/projects/.
|
|
10
|
+
*
|
|
11
|
+
* NOTE: with `--max-turns 1` the process exits NON-ZERO on success (error_max_turns), so exit
|
|
12
|
+
* code can't signal validity. We treat a probe as valid iff the model produced an assistant
|
|
13
|
+
* message; otherwise it's an infra error. */
|
|
14
|
+
import type { ProbeEnv, ProbeOpts, ProbeResult, RuntimeAdapter } from "../types.ts";
|
|
2
15
|
export interface ParseInfo {
|
|
3
16
|
skillFired: string | null;
|
|
4
17
|
trajectory: string[];
|
|
5
18
|
cost: number;
|
|
6
19
|
sawInit: boolean;
|
|
7
20
|
sawAssistant: boolean;
|
|
21
|
+
env: ProbeEnv;
|
|
8
22
|
}
|
|
9
23
|
export declare function parseStream(stdout: string): ParseInfo;
|
|
10
24
|
/** Decide what a finished (non-timeout) probe means. A probe is a VALID behavioral outcome only
|
|
@@ -14,4 +28,6 @@ export declare function parseStream(stdout: string): ParseInfo;
|
|
|
14
28
|
* counting it as "no skill fired" silently poisons every verdict. It must surface as an infra
|
|
15
29
|
* error → untrustworthy, never as a behavioral result. */
|
|
16
30
|
export declare function interpretProbe(info: ParseInfo, stderrText: string): ProbeResult;
|
|
31
|
+
/** CLI arguments for one probe. Exported for testing. */
|
|
32
|
+
export declare function buildArgs(prompt: string, opts: Omit<ProbeOpts, "cwd">): string[];
|
|
17
33
|
export declare const claudeCodeAdapter: RuntimeAdapter;
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
* for `tool_use name=="Skill"`. Distinguishes a real behavioral outcome (the model ran) from
|
|
3
|
-
* an infrastructure failure (timeout / auth / crash / empty output) — finding #1.
|
|
4
|
-
*
|
|
5
|
-
* NOTE: with `--max-turns 1` the process exits NON-ZERO on success (error_max_turns), so exit
|
|
6
|
-
* code can't signal validity. We treat a probe as valid iff the model produced an assistant
|
|
7
|
-
* message; otherwise it's an infra error. */
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { runProcess } from "../proc.js";
|
|
9
2
|
export function parseStream(stdout) {
|
|
10
3
|
let skillFired = null;
|
|
11
4
|
let cost = 0, sawInit = false, sawAssistant = false;
|
|
12
5
|
const trajectory = [];
|
|
6
|
+
const env = {};
|
|
13
7
|
for (const raw of stdout.split("\n")) {
|
|
14
8
|
const line = raw.trim();
|
|
15
9
|
if (!line)
|
|
@@ -21,8 +15,15 @@ export function parseStream(stdout) {
|
|
|
21
15
|
catch {
|
|
22
16
|
continue;
|
|
23
17
|
}
|
|
24
|
-
if (e.type === "system" && e.subtype === "init")
|
|
18
|
+
if (e.type === "system" && e.subtype === "init") {
|
|
25
19
|
sawInit = true;
|
|
20
|
+
if (Array.isArray(e.skills))
|
|
21
|
+
env.loadedSkills = e.skills.filter((s) => typeof s === "string");
|
|
22
|
+
if (typeof e.claude_code_version === "string")
|
|
23
|
+
env.runtimeVersion = e.claude_code_version;
|
|
24
|
+
if (typeof e.model === "string")
|
|
25
|
+
env.model = e.model;
|
|
26
|
+
}
|
|
26
27
|
else if (e.type === "assistant") {
|
|
27
28
|
sawAssistant = true;
|
|
28
29
|
for (const b of e.message?.content ?? []) {
|
|
@@ -37,7 +38,7 @@ export function parseStream(stdout) {
|
|
|
37
38
|
cost = e.total_cost_usd;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
|
-
return { skillFired, trajectory, cost, sawInit, sawAssistant };
|
|
41
|
+
return { skillFired, trajectory, cost, sawInit, sawAssistant, env };
|
|
41
42
|
}
|
|
42
43
|
/** Decide what a finished (non-timeout) probe means. A probe is a VALID behavioral outcome only
|
|
43
44
|
* if there is evidence the model actually ran: assistant output AND a nonzero billed cost. A
|
|
@@ -46,54 +47,57 @@ export function parseStream(stdout) {
|
|
|
46
47
|
* counting it as "no skill fired" silently poisons every verdict. It must surface as an infra
|
|
47
48
|
* error → untrustworthy, never as a behavioral result. */
|
|
48
49
|
export function interpretProbe(info, stderrText) {
|
|
50
|
+
const env = Object.keys(info.env).length ? { env: info.env } : {};
|
|
49
51
|
if (!info.sawAssistant) {
|
|
52
|
+
const err = stderrText.trim();
|
|
53
|
+
const hint = /unknown option|unrecognized/i.test(err)
|
|
54
|
+
? " — this Claude Code version doesn't support a flag skill-probe needs; upgrade with `npm i -g @anthropic-ai/claude-code`"
|
|
55
|
+
: "";
|
|
50
56
|
return {
|
|
51
57
|
status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
|
|
52
|
-
error:
|
|
58
|
+
error: (err ? err.slice(-200) : "no assistant output (auth/config/empty response?)") + hint, ...env,
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
if (info.cost === 0) {
|
|
56
62
|
return {
|
|
57
63
|
status: "error", skillFired: null, trajectory: info.trajectory, cost: 0,
|
|
58
|
-
error: "zero-cost response — the model did not actually run (usage limit / degraded CLI state?)",
|
|
64
|
+
error: "zero-cost response — the model did not actually run (usage limit / degraded CLI state?)", ...env,
|
|
59
65
|
};
|
|
60
66
|
}
|
|
61
|
-
return { status: "ok", skillFired: info.skillFired, trajectory: info.trajectory, cost: info.cost };
|
|
67
|
+
return { status: "ok", skillFired: info.skillFired, trajectory: info.trajectory, cost: info.cost, ...env };
|
|
68
|
+
}
|
|
69
|
+
/** CLI arguments for one probe. Exported for testing. */
|
|
70
|
+
export function buildArgs(prompt, opts) {
|
|
71
|
+
const args = [
|
|
72
|
+
"-p", prompt, "--output-format", "stream-json", "--verbose",
|
|
73
|
+
"--max-turns", String(opts.maxTurns ?? 1), "--no-session-persistence",
|
|
74
|
+
];
|
|
75
|
+
if (!opts.userConfig)
|
|
76
|
+
args.push("--setting-sources", "project", "--strict-mcp-config");
|
|
77
|
+
if (opts.model)
|
|
78
|
+
args.push("--model", opts.model);
|
|
79
|
+
return args;
|
|
62
80
|
}
|
|
63
81
|
export const claudeCodeAdapter = {
|
|
64
82
|
name: "claude-code",
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let out = "", err = "";
|
|
73
|
-
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
|
|
74
|
-
const finish = (r) => {
|
|
75
|
-
if (settled)
|
|
76
|
-
return;
|
|
77
|
-
settled = true;
|
|
78
|
-
clearTimeout(timer);
|
|
79
|
-
resolve(r);
|
|
80
|
-
};
|
|
81
|
-
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
82
|
-
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
83
|
-
child.on("error", (e) => finish({
|
|
83
|
+
isolatesUserConfig: true,
|
|
84
|
+
supportsMaxTurns: true,
|
|
85
|
+
async probe(prompt, opts) {
|
|
86
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
87
|
+
const r = await runProcess("claude", buildArgs(prompt, opts), { cwd: opts.cwd, timeoutMs });
|
|
88
|
+
if (r.spawnError) {
|
|
89
|
+
return {
|
|
84
90
|
status: "error", skillFired: null, trajectory: [], cost: 0,
|
|
85
|
-
error: `spawn failed: ${
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
97
|
-
});
|
|
91
|
+
error: `spawn failed: ${r.spawnError} (is 'claude' installed and on PATH?)`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const info = parseStream(r.stdout);
|
|
95
|
+
if (r.timedOut) {
|
|
96
|
+
return {
|
|
97
|
+
status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
|
|
98
|
+
error: `timed out after ${timeoutMs}ms`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return interpretProbe(info, r.stderr);
|
|
98
102
|
},
|
|
99
103
|
};
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/** OpenCode adapter — the second runtime (cross-vendor; BYO-key). Reads `.claude/skills/`.
|
|
2
|
+
* Activation surfaces as a tool part named "skill" with the name in state.input.name (confirmed
|
|
3
|
+
* against opencode v1.17.9 `run --format json`). Distinguishes behavioral outcome from infra
|
|
4
|
+
* failure (finding #1): a run counts only if there's evidence a model step actually ran — an
|
|
5
|
+
* `{"type":"error"}` event (auth, quota, version gate; captured live on 1.17.9) or output with no
|
|
6
|
+
* model step at all is an infrastructure error, never a behavioral "no skill fired".
|
|
7
|
+
* GOTCHA: opencode `run` hangs unless stdin is closed — runProcess uses stdio ignore. */
|
|
8
|
+
import type { ProbeResult, RuntimeAdapter } from "../types.ts";
|
|
2
9
|
export interface CoreResult {
|
|
3
10
|
skillFired: string | null;
|
|
4
11
|
trajectory: string[];
|
|
5
12
|
cost: number;
|
|
13
|
+
/** saw a model step / text / tool part — evidence the model actually ran */
|
|
14
|
+
sawModelOutput: boolean;
|
|
15
|
+
/** message of an error event, if the run reported one */
|
|
16
|
+
error?: string;
|
|
6
17
|
}
|
|
7
18
|
/** Recursively scan parsed OpenCode output for a fired skill + tool trajectory. Defensive:
|
|
8
19
|
* handles "skill name in input.name" or "input.skill", single JSON or JSONL of events. */
|
|
@@ -12,4 +23,6 @@ export declare function parseOutput(stdout: string): {
|
|
|
12
23
|
result: CoreResult;
|
|
13
24
|
eventCount: number;
|
|
14
25
|
};
|
|
26
|
+
/** Decide what a finished (non-timeout) run means. Exported for testing. */
|
|
27
|
+
export declare function interpretOpenCode(stdout: string, stderr: string): ProbeResult;
|
|
15
28
|
export declare const openCodeAdapter: RuntimeAdapter;
|