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 CHANGED
@@ -1,10 +1,13 @@
1
1
  # skill-probe
2
2
 
3
+ [![ci](https://github.com/HystonKayange/skill-probe/actions/workflows/ci.yml/badge.svg)](https://github.com/HystonKayange/skill-probe/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/skill-probe)](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
- ![Terminal demo: greeter fires 100% in isolation but only 30% co-loaded — trigger-theft by welcomer, caught with Fisher's exact test](assets/skill-probe-context-demo.gif)
10
+ ![Terminal demo: greeter fires 100% in isolation but 0% co-loaded; leave-one-out ablation removes welcomer, activation fully recovers (p=0.012) — thief named with statistical evidence](assets/skill-probe-thief-demo.gif)
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
- result is statistically decided, and flags **trigger-theft** (a sibling stealing a trigger).
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
- WARN threshold 90% with k=10 cannot certify a pass — need ~k=35
69
- FAIL skill "greeter" missing description:
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:`, names match folders, the config parses, every `expected` skill exists, the
74
- threshold/k are statistically achievable, the runtime CLI is installed, and a harmless live probe
75
- authenticates. Exit `0` healthy · `1` warnings only · `2` hard failures. Use `--skip-probe` to skip
76
- the live (costing) auth check.
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 / trigger-theft, `2` inconclusive or an
141
- infrastructure error (runtime down → never a silent pass).
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 confidence intervals are wide at small k:
153
- - **Smoke (default):** `threshold 0.7, k 10` — a clean run certifies; cheap; good for CI.
154
- - **Certify:** a strict bar needs more runs. To certify **≥0.9** you need **~k=35**; when a
155
- case is inconclusive the report prints the exact k for your threshold. Don't set `threshold
156
- 0.9, k 10` and expect a pass — that's statistically impossible and the tool will say so.
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 (the claude/opencode CLI must be installed + authed on the runner)
164
- - run: npx skill-probe --config probe.config.json --quiet --no-cost
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
- Exit codes: **`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference · **`2`**
168
- inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
169
- passing. Add `--json` to archive the full result as a build artifact.
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 no single removal
237
- recovers activation, it says so ("interference may be combinatorial") rather than naming an
238
- innocent sibling.
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 an **interleaved** before/after (old desc → probe → new desc → probe, paired, to control
292
- for drift); (3) computes the **Bayesian** P(improvement) + a credible interval on the change; and
293
- (4) **applies the rewrite only if** `P(improvement) ≥ --apply-threshold` (default 0.9) **and** the
294
- effect is positive — otherwise reverts. When applied, the original is snapshotted to a timestamped
295
- `SKILL.md.bak.<timestamp>` (no backup is left behind on a revert).
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 stopping + four-state verdict
320
- (pass / fail / inconclusive / **error**), across two runtimes (Claude Code, OpenCode).
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 now wired into a command: Wilson + sequential
339
- stopping (audit), Fisher's exact + Benjamini-Hochberg (context/ablate), Bayesian Beta-Binomial (fix).
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.
@@ -1,10 +1,24 @@
1
- import type { ProbeResult, RuntimeAdapter } from "../types.ts";
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
- /** 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
- * 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: stderrText.trim() ? stderrText.trim().slice(-200) : "no assistant output (auth/config/empty response?)",
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
- probe(prompt, { cwd, timeoutMs = 120_000, model }) {
66
- return new Promise((resolve) => {
67
- let settled = false, timedOut = false;
68
- const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", "1"];
69
- if (model)
70
- args.push("--model", model);
71
- const child = spawn("claude", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
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: ${e.message} (is 'claude' installed and on PATH?)`,
86
- }));
87
- child.on("close", () => {
88
- const info = parseStream(out);
89
- if (timedOut) {
90
- return finish({
91
- status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
92
- error: `timed out after ${timeoutMs}ms`,
93
- });
94
- }
95
- finish(interpretProbe(info, err));
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
- import type { RuntimeAdapter } from "../types.ts";
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;