skill-probe 0.9.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
 
@@ -38,8 +49,10 @@ doctor → gen → audit → context → diagnose → fix
38
49
 
39
50
  - Skill activation is stochastic: the same prompt can produce different skill-routing outcomes.
40
51
  - A single run lies (we measured the same prompt at 0/5 one batch, 2/3 the next).
41
- - skill-probe runs each prompt **k times**, reports a **Wilson 95% CI**, stops early when the
42
- 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).
43
56
 
44
57
  ## Install
45
58
 
@@ -65,16 +78,21 @@ PASS config parsed: probe.config.json
65
78
  PASS found .claude/skills/ with 4 skills
66
79
  PASS expected skills all exist
67
80
  PASS claude CLI found
68
- PASS claude-code probe succeeded (CLI is authenticated)
69
- WARN threshold 90% with k=10 cannot certify a pass — need ~k=35
70
- 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)
71
87
  ```
72
88
 
73
89
  It checks: Node version, `.claude/skills/` exists, every skill has a `SKILL.md` with `name:` +
74
- `description:`, names match folders, the config parses, every `expected` skill exists, the
75
- threshold/k are statistically achievable, the runtime CLI is installed, and a harmless live probe
76
- authenticates. Exit `0` healthy · `1` warnings only · `2` hard failures. Use `--skip-probe` to skip
77
- 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.
78
96
 
79
97
  ## Generate a config (`skill-probe gen`)
80
98
 
@@ -120,6 +138,7 @@ Config (`skill-probe.config.json`):
120
138
  Output:
121
139
  ```
122
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)
123
142
 
124
143
  PASS expect=commit-writer | write a commit message
125
144
  reliability 100% [72%, 100%] k=10
@@ -127,7 +146,7 @@ skill-probe — runtime: claude-code model: (runtime default) threshold: 70%
127
146
  FAIL expect=pr-describer | write a pull request description
128
147
  reliability 20% [6%, 51%] k=10
129
148
  outcomes: None×8, commit-writer×2
130
- ⚠ trigger-theft by: commit-writer
149
+ ⚠ trigger-theft by: commit-writer 2/10
131
150
  PASS expect=(none) | what's the weather?
132
151
  reliability 100% [72%, 100%] k=10
133
152
  outcomes: None×10
@@ -138,28 +157,62 @@ Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
138
157
  - `cwd` (relative paths resolve against the **config file's** directory) is a project dir
139
158
  containing `.claude/skills/`.
140
159
  - `expected: null` = a decoy that should fire nothing.
141
- - Exit code: `0` all pass, `1` a behavioral fail / trigger-theft, `2` inconclusive or an
142
- 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).
143
166
 
144
167
  **Output options:** a live `probing [2/3] …` progress line prints to stderr while it runs (use
145
168
  `--quiet` to silence it). `--markdown` emits a table you can paste into a PR/README; `--json`
146
169
  for machine output; `--no-cost` hides the cost line (handy on a Claude subscription, where the
147
170
  dollar figure is just an estimate, not a charge).
148
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
+
149
184
  **Cross-runtime comparison must pin the model.** A skill can fire on one runtime and not another
150
185
  partly because of the *model*, not the runtime. Always set `model` (recorded in the report) so a
151
186
  Claude Code vs OpenCode comparison is fair — otherwise you're comparing two confounded variables.
152
187
 
153
- **Two modes** — because confidence intervals are wide at small k:
154
- - **Smoke (default):** `threshold 0.7, k 10` — a clean run certifies; cheap; good for CI.
155
- - **Certify:** a strict bar needs more runs. To certify **≥0.9** you need **~k=35**; when a
156
- case is inconclusive the report prints the exact k for your threshold. Don't set `threshold
157
- 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.
158
211
 
159
212
  ## CI usage
160
213
 
161
214
  skill-probe exits non-zero on a real problem, so it drops straight into a pipeline. Exit codes:
162
- **`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference / regression · **`2`**
215
+ **`0`** all pass · **`1`** a behavioral fail / interference / regression (trigger-theft only with `--fail-on-theft`) · **`2`**
163
216
  inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
164
217
  passing. Add `--json` to archive the full result (it includes a **run manifest**: tool version,
165
218
  model, date, config hash — so two runs are comparable and a report is citable).
@@ -213,9 +266,16 @@ jobs:
213
266
 
214
267
  The action installs skill-probe plus the runtime CLI (Claude Code by default; set
215
268
  `runtime-package: ""` if your runner already has one) and runs the command. `command:` selects
216
- `audit` (default), `context`, `diagnose`, or `doctor`; `args:` passes extra flags like `--ablate`.
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.
217
273
  The runtime authenticates via `ANTHROPIC_API_KEY` — headless probes each cost real tokens, so
218
- smoke settings (`k: 10`, few cases) are the sweet spot for per-PR gating.
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 Δ).
219
279
 
220
280
  ## Activation rate by context (`skill-probe context`)
221
281
 
@@ -248,6 +308,14 @@ skill-probe context — runtime: claude-code model: (runtime default) threshol
248
308
  Result: 1 interference / 3 measured | exit 1
249
309
  ```
250
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
+
251
319
  This is the "fires in isolation, fails under load X" case the aggregate rate hides. Decoy cases
252
320
  (`expected: null`) are skipped — there's no single skill to isolate; an `expected` skill that isn't
253
321
  in the library is a config error (typo), surfaced before any probes run. Exit `1` if any skill
@@ -282,9 +350,11 @@ Result: 1 interference / 1 measured | thieves named: welcomer | exit 1
282
350
 
283
351
  (That's a real run.) Honest edge cases: if the co-loaded probes were suppressed to **None** —
284
352
  nothing stole them, the skill just stops firing under load — there's no suspect to remove, and the
285
- report says *"dilution, not theft; nothing to ablate"* instead of guessing. If no single removal
286
- recovers activation, it says so ("interference may be combinatorial") rather than naming an
287
- 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.
288
358
 
289
359
  ## Why a skill fails (`skill-probe diagnose`)
290
360
 
@@ -337,11 +407,15 @@ ANTHROPIC_API_KEY=sk-ant-... \
337
407
  ```
338
408
 
339
409
  It (1) LLM-rewrites the description, told the sibling skills so it won't steal their triggers;
340
- (2) runs an **interleaved** before/after (old desc → probe → new desc → probe, paired, to control
341
- for drift); (3) computes the **Bayesian** P(improvement) + a credible interval on the change; and
342
- (4) **applies the rewrite only if** `P(improvement) ≥ --apply-threshold` (default 0.9) **and** the
343
- effect is positive — otherwise reverts. When applied, the original is snapshotted to a timestamped
344
- `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.
345
419
 
346
420
  ```
347
421
  before: 0% [0%, 49%] after: 100% [51%, 100%] (4 paired runs)
@@ -365,12 +439,15 @@ probe.
365
439
  **Gen (`skill-probe gen`):** drafts a reviewable probe config from existing skills, including
366
440
  should-fire prompts, near-misses, and decoys.
367
441
 
368
- **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping + four-state verdict
369
- (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.
370
446
  Infrastructure failures (timeout / auth / crash / empty output / a zero-cost response where the
371
447
  model never actually ran, e.g. a usage-limit window) are reported as `error`, never as a
372
- behavioral pass/fail — a decoy can't falsely pass because the runtime was down. Every run carries
373
- a **manifest** (version, model, date, config hash); `--save-baseline` / `--baseline` turn audits
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
374
451
  into a CI regression gate (Fisher + BH — noise passes, real drops fail), and a **GitHub Action**
375
452
  (`HystonKayange/skill-probe@main`) wraps the whole thing for per-PR gating.
376
453
 
@@ -387,8 +464,9 @@ is a heuristic, not ground truth, and the README calls that out explicitly.
387
464
  **Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
388
465
  *proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
389
466
 
390
- Every statistical function in `src/stats.ts` is now wired into a command: Wilson + sequential
391
- 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).
392
470
 
393
471
  ## Dev
394
472
 
@@ -396,4 +474,9 @@ stopping (audit), Fisher's exact + Benjamini-Hochberg (context/ablate), Bayesian
396
474
  node --test test/*.test.ts # run tests (zero deps; Node >= 22.18 strips types)
397
475
  node src/cli.ts --help
398
476
  npm run typecheck # tsc --noEmit (needs `npm i`)
477
+ npm run build # compile to dist/ (what the npm package runs)
399
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;
@@ -1,13 +1,27 @@
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). GOTCHA: opencode `run` hangs unless stdin is closed — we use stdio ignore. */
5
- import { spawn } from "node:child_process";
1
+ import { runProcess } from "../proc.js";
2
+ const MODEL_OUTPUT_TYPES = new Set([
3
+ "step_start", "step_finish", "step-start", "step-finish", "text", "tool", "tool_use", "reasoning",
4
+ ]);
5
+ function errorMessage(o) {
6
+ const e = o["error"];
7
+ if (typeof e === "string")
8
+ return e;
9
+ if (e && typeof e === "object") {
10
+ const eo = e;
11
+ const data = eo["data"];
12
+ const msg = data?.["message"] ?? eo["message"] ?? eo["name"];
13
+ if (typeof msg === "string")
14
+ return msg;
15
+ }
16
+ return typeof o["message"] === "string" ? o["message"] : "opencode reported an error";
17
+ }
6
18
  /** Recursively scan parsed OpenCode output for a fired skill + tool trajectory. Defensive:
7
19
  * handles "skill name in input.name" or "input.skill", single JSON or JSONL of events. */
8
20
  export function extractFromOpenCode(parsed) {
9
21
  let skillFired = null;
10
22
  let cost = 0;
23
+ let sawModelOutput = false;
24
+ let error;
11
25
  const trajectory = [];
12
26
  const visit = (node) => {
13
27
  if (Array.isArray(node)) {
@@ -17,7 +31,12 @@ export function extractFromOpenCode(parsed) {
17
31
  if (!node || typeof node !== "object")
18
32
  return;
19
33
  const o = node;
20
- if (o["type"] === "tool" && typeof o["tool"] === "string") {
34
+ const type = o["type"];
35
+ if (type === "error" && error === undefined)
36
+ error = errorMessage(o);
37
+ if (typeof type === "string" && MODEL_OUTPUT_TYPES.has(type))
38
+ sawModelOutput = true;
39
+ if (type === "tool" && typeof o["tool"] === "string") {
21
40
  const tool = o["tool"];
22
41
  trajectory.push(tool);
23
42
  if (tool === "skill") {
@@ -34,13 +53,14 @@ export function extractFromOpenCode(parsed) {
34
53
  visit(v);
35
54
  };
36
55
  visit(parsed);
37
- return { skillFired, trajectory, cost };
56
+ return { skillFired, trajectory, cost, sawModelOutput, ...(error !== undefined ? { error } : {}) };
38
57
  }
39
58
  /** Parse stdout (whole-JSON or JSONL) and report how many events were seen (0 = no output). */
40
59
  export function parseOutput(stdout) {
41
60
  const trimmed = stdout.trim();
42
- if (!trimmed)
43
- return { result: { skillFired: null, trajectory: [], cost: 0 }, eventCount: 0 };
61
+ if (!trimmed) {
62
+ return { result: { skillFired: null, trajectory: [], cost: 0, sawModelOutput: false }, eventCount: 0 };
63
+ }
44
64
  try {
45
65
  const whole = JSON.parse(trimmed);
46
66
  return { result: extractFromOpenCode(whole), eventCount: Array.isArray(whole) ? whole.length : 1 };
@@ -59,51 +79,48 @@ export function parseOutput(stdout) {
59
79
  return { result: extractFromOpenCode(events), eventCount: events.length };
60
80
  }
61
81
  }
82
+ /** Decide what a finished (non-timeout) run means. Exported for testing. */
83
+ export function interpretOpenCode(stdout, stderr) {
84
+ const { result, eventCount } = parseOutput(stdout);
85
+ const fail = (error) => ({ status: "error", skillFired: null, trajectory: result.trajectory, cost: result.cost, error });
86
+ if (eventCount === 0) {
87
+ // surface whatever diagnostic we have: stderr, else a non-JSON stdout log (opencode
88
+ // prints provider/model errors to stdout), else a generic hint.
89
+ const diag = stderr.trim() || stdout.trim();
90
+ return fail(diag ? diag.slice(-200) : "no output (auth/model/hang? check `opencode auth list`)");
91
+ }
92
+ if (result.error !== undefined)
93
+ return fail(`opencode error: ${result.error.slice(0, 200)}`);
94
+ if (!result.sawModelOutput) {
95
+ return fail("no model step in the output — the model did not actually run (check `opencode auth list`)");
96
+ }
97
+ return { status: "ok", skillFired: result.skillFired, trajectory: result.trajectory, cost: result.cost };
98
+ }
62
99
  export const openCodeAdapter = {
63
100
  name: "opencode",
64
- probe(prompt, { cwd, timeoutMs = 120_000, model }) {
101
+ // `opencode run` always loads its global config and has no turn limit — say so, don't pretend
102
+ isolatesUserConfig: false,
103
+ supportsMaxTurns: false,
104
+ async probe(prompt, { cwd, timeoutMs = 120_000, model }) {
65
105
  // config/CLI --model wins; env var is a fallback convenience
66
106
  const effectiveModel = model ?? process.env["SKILL_PROBE_OPENCODE_MODEL"];
67
107
  const args = ["run", prompt, "--format", "json"];
68
108
  if (effectiveModel)
69
109
  args.push("--model", effectiveModel);
70
- return new Promise((resolve) => {
71
- let settled = false, timedOut = false;
72
- const child = spawn("opencode", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
73
- let out = "", err = "";
74
- const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
75
- const finish = (r) => {
76
- if (settled)
77
- return;
78
- settled = true;
79
- clearTimeout(timer);
80
- resolve(r);
81
- };
82
- child.stdout.on("data", (d) => { out += d.toString(); });
83
- child.stderr.on("data", (d) => { err += d.toString(); });
84
- child.on("error", (e) => finish({
110
+ const r = await runProcess("opencode", args, { cwd, timeoutMs });
111
+ if (r.spawnError) {
112
+ return {
85
113
  status: "error", skillFired: null, trajectory: [], cost: 0,
86
- error: `spawn failed: ${e.message} (is 'opencode' installed and on PATH?)`,
87
- }));
88
- child.on("close", () => {
89
- const { result, eventCount } = parseOutput(out);
90
- if (timedOut) {
91
- return finish({
92
- status: "error", skillFired: null, trajectory: result.trajectory, cost: result.cost,
93
- error: `timed out after ${timeoutMs}ms`,
94
- });
95
- }
96
- if (eventCount === 0) {
97
- // surface whatever diagnostic we have: stderr, else a non-JSON stdout log (opencode
98
- // prints provider/model errors to stdout), else a generic hint.
99
- const diag = err.trim() || out.trim();
100
- return finish({
101
- status: "error", skillFired: null, trajectory: [], cost: 0,
102
- error: diag ? diag.slice(-200) : "no output (auth/model/hang? check `opencode auth list`)",
103
- });
104
- }
105
- finish({ status: "ok", skillFired: result.skillFired, trajectory: result.trajectory, cost: result.cost });
106
- });
107
- });
114
+ error: `spawn failed: ${r.spawnError} (is 'opencode' installed and on PATH?)`,
115
+ };
116
+ }
117
+ if (r.timedOut) {
118
+ const { result } = parseOutput(r.stdout);
119
+ return {
120
+ status: "error", skillFired: null, trajectory: result.trajectory, cost: result.cost,
121
+ error: `timed out after ${timeoutMs}ms`,
122
+ };
123
+ }
124
+ return interpretOpenCode(r.stdout, r.stderr);
108
125
  },
109
126
  };
package/dist/baseline.js CHANGED
Binary file