feat(skills): measure a skill reached from another skill - #2245
Conversation
`skills:eval` ran every case with `--max-turns 1`, so it measured one
thing: whether a skill is the model's FIRST tool call. That is right for a
skill a user reaches directly, and it leaves a whole class unmeasured — a
skill reached from inside another skill's body. `testing` tells the model
that picking a fixture is `/test-servers` and that it has to load it, and
`test-servers` scores 5/5 while every one of those five cases asks for it
by name. A skill only ever reachable through a hand-off would score a
clean 100% with the hand-off silently never firing.
An eval case is now one of two shapes, and exactly one:
{ "prompt": "…", "expect": "test-servers" } first move, 1 turn
{ "prompt": "…", "chain": ["testing", "test-servers"] } hand-off, 14 turns
A chain must END with the skill whose file it lives in. The case exists to
measure whether THIS skill is reachable, so the file that goes red is the
one belonging to the skill that stopped being reached; anchoring on the
first link would file a `test-servers` measurement under `testing`, where
a `test-servers` description edit would never be seen. Links are checked
against the repo's model-invoked set, which is why both `verify:skills`
and `collectCases` now validate in a second pass — `test-servers` sorts
before `testing`, so a per-directory check would reject a live chain as
unknown purely because of where the alphabet put it.
Scoring is an ordered SUBSEQUENCE, not a prefix and not a contiguous run:
the model may load something before the chain starts and something
unrelated in between, and neither changes the claim that A led to B.
`collectSkillInvocations` therefore returns an ordered array rather than a
Set — a B, A, B run has to stay distinguishable from one that never
reached B from A.
The two rates are reported in separate columns and never summed. A
hand-off is a second-hop load over many turns; a first-move rate is the
model's opening move. `CHAIN_MAX_TURNS` (14) and `CHAIN_THRESHOLD` are
their own knobs for the same reason, and `CHAIN_THRESHOLD` defaults to
**0.5** rather than inheriting 0.8: see the measurement below.
Read-only containment no longer leans on `--max-turns 1`, which was doing
much of it by itself. The deny list gains the agentic and network tools —
`Task` in particular, whose subagent the flag does not reach.
Measured, `skills:eval -- test-servers`:
First move (1 turn) 7/7 at 100%, unchanged
Hand-off (14 turns) 67% and 33% at RUNS=3
So the hand-off is real and unreliable — which is the fact nothing could
observe before this change, and the reason 0.5 rather than 0.8 is the
default bar: at 0.8 both committed cases are red no matter how strongly
the first skill points at the second, and the column stops carrying
signal. A `["pr-flow", "test-servers"]` probe measured 0% and was dropped
rather than kept: `pr-flow` says nothing about fixtures, so the case had
no lever short of broadening a description onto another skill's ground.
Both findings are written up in `docs/skill-authoring.md`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe
Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
The committed cases can false-pass without a real hand-off, and multi-turn execution is not fully contained.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds multi-turn skill hand-off measurement to the repository’s skill evaluation tooling.
Changes:
- Introduces validated
chaincases with ordered-subsequence scoring. - Separates first-move and hand-off reporting and thresholds.
- Adds tests, documentation, and initial
testing → test-serverscases.
File summaries
| File | Description |
|---|---|
.claude/skills/test-servers/evals/evals.json |
Adds hand-off evaluation cases. |
AGENTS.md |
Documents chain-case requirements. |
docs/skill-authoring.md |
Adds hand-off authoring guidance. |
scripts/lib/skill-manifest.mjs |
Validates chain case structure and links. |
scripts/lib/skill-manifest.test.mjs |
Tests chain validation. |
scripts/skill-eval.mjs |
Runs, scores, and reports multi-turn chains. |
scripts/skill-eval.test.mjs |
Tests invocation ordering and turn policies. |
scripts/verify-skills.main.test.mjs |
Tests repository-wide link validation. |
scripts/verify-skills.mjs |
Performs two-pass chain validation. |
Review details
Suppressed comments (1)
.claude/skills/test-servers/evals/evals.json:30
- This second chain case also names the target's direct trigger (“live server”), so it measures two independently matching skills in order rather than whether
testingcaused the hand-off. Phrase it as a testing task without the server cue; thetestingskill should be what introduces the need fortest-servers.
"prompt": "Add end-to-end coverage for tool-list pagination against a live server.",
- Files reviewed: 9/9 changed files
- Comments generated: 5
- Review effort level: Balanced
Five findings, all real.
**The chain prompts carried the target skill's own trigger.** `test-servers`
claims "a change needs a real server to exercise it", and both cases said
"against a real/live server" — so the model could load `testing`, then pick
`test-servers` from the ORIGINAL prompt, in that order, and score a hit that
would have survived deleting the pointer from `testing` entirely. Rewritten to
carry no server cue ("end to end"), and the measured rate fell from 100% / 67%
to 33% / 33%. That is the size of the artefact, and 33% is what the pointer
alone is worth. Both cases are now red against the 0.5 bar and stay that way:
`skills:eval` is not a gate, the number IS the finding, and lowering the bar to
turn the column green would discard the only signal this feature adds. Filed as
#2247 with the trap written down so a fix cannot re-measure the artefact.
**A deny list cannot bound a 14-turn run.** It only names tools known when it
was written, and this checkout configures an HTTP `mcp-docs` server in
`.mcp.json` while a contributor's own MCP servers and plugins add more. Now
`--allowedTools Read,Glob,Grep,Skill` (what the harness actually needs) plus
`--strict-mcp-config` with no `--mcp-config`, which drops every configured
server. The deny list stays alongside, since a deny is unconditional while an
allow list governs pre-approval. What remains outside all three is a
contributor's plugin tools; `--bare` would remove those and skills with them,
which would measure nothing — so that residual is stated rather than papered
over.
**The chain threshold compared inclusively.** "More often than not" is `> 0.5`,
and `>=` passes exactly half whenever RUNS is even — 2/4 would report a result
the criterion does not license. `passesThreshold` is now strict for chains and a
floor for first moves, and a strict bound of 1.0 is rejected up front rather
than failing every case while looking like a trigger problem.
**The doc's probe command still denied only four tools**, contradicting the
section's own promise that a probe and a scored case see the same policy.
Updated, with a note that it must move with the harness, plus a hand-off probe
form.
**The reporting had no automated coverage** — the one thing that could silently
merge the two measurements lived inside `main`. Extracted as `formatReport` and
tested: both headings, per-group thresholds, the two separate summaries, the
combined exit, and both single-kind selections.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe
Signed-off-by: cliffhall <cliff@futurescale.com>
Round 1 — all five implemented, in
|
| before | after | |
|---|---|---|
Write an integration test that exercises tool listing … |
"…against a real server" — 100% | "…end to end" — 33% |
Add end-to-end coverage for tool-list pagination … |
"…against a live server" — 67% | "…the tool-list pagination path" — 33% |
That gap is the artefact, and 33% is what the pointer alone is worth. Both cases are now red against the 0.5 bar and stay that way — skills:eval is deliberately not a gate, the number is the finding, and lowering the bar to turn the column green would discard the only signal this feature adds. Filed as #2247 with the trap written down, so a fix can't re-measure the artefact. The doc gained a
2. A deny list cannot bound a 14-turn run (skill-eval.mjs) — fixed. The .mcp.json observation was exact. Now:
--allowedTools Read,Glob,Grep,Skill # what the harness actually needs
--disallowedTools <the 10> # kept: a deny is unconditional, an allow list governs pre-approval
--strict-mcp-config # with no --mcp-config, so every configured server is dropped
One residual I've stated rather than papered over: a contributor's own plugin tools are outside all three mechanisms. --bare would remove them — and skills with them, which would measure nothing.
3. CHAIN_THRESHOLD compared inclusively — fixed. passesThreshold(rate, threshold, strict) is strict for chains (> 0.5, so 2/4 no longer passes) and a floor for first moves (4/5 still clears 0.8). A strict bound of 1.0 is unreachable, so it's now rejected up front rather than failing every case while looking like a trigger problem.
4. The probe command in the doc was stale — fixed. It now carries the same allow/deny/--strict-mcp-config policy, with a runPrompt in the same edit, plus a hand-off probe form at --max-turns 14.
5. The reporting had no coverage — fixed. formatReport is extracted and exported, and four new cases assert both headings, the per-group thresholds, the two separate summary lines, that no line merges them, the combined exit, and both single-kind selections.
npm run test:scripts is 517 → 521 passing; npm run local:gate re-run against the fixes.
There was a problem hiding this comment.
🟡 Changes recommended
Tool containment is not fully enforced, and malformed chain thresholds can produce misleading results.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Balanced
Two findings, both correct, and the first one means round 1's containment fix was only half a fix. **`--allowedTools` is not the availability filter.** It grants permission; it does not restrict which tools exist. So a tool a user's or a plugin's settings already permit stayed reachable across all 14 turns, and the bound still rested on the deny list being complete — the exact property round 1 set out to stop depending on. `--tools` is the real restriction (it selects from the built-in set), and both flags now carry the same four names: `--tools` to bound, `--allowedTools` so those four never need a permission prompt no headless run can answer. Verified against the real CLI — the full flag set runs to a clean `end_turn`. The test asserts both flags, and both probe snippets in `docs/skill-authoring.md` carry `--tools` with a⚠️ saying which one does the bounding. **The threshold guard let `NaN` and negatives through.** `CHAIN_THRESHOLD=abc` is `NaN`, which fails every comparison and prints an `above NaN%` summary; a negative bar passes every chain unconditionally. Both turn an advertised env knob into a measurement that quietly means nothing. `CHAIN_THRESHOLD` must now be in `[0, 1)` — half-open because the chain bar is strict — and `THRESHOLD` in `[0, 1]`, inclusive because a first-move floor of 1.0 is meetable. The message names the offending value. A new case spawns the script for each bad value rather than asserting on the predicate, since these guards live in `main`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe Signed-off-by: cliffhall <cliff@futurescale.com>
Round 2 — both implemented, in
|
Gate status, stated plainly
Both failures are contention, not regression, and I checked rather than assumed:
Every other stage was green in all three runs: lint, format, typecheck, build, the four client suites (326 / 27 / 27 / 1 files), coverage thresholds, Also merged |
There was a problem hiding this comment.
🟡 Changes recommended
Flattening calls across assistant messages can falsely report parallel skill invocations as a causal hand-off.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Balanced
One finding, and a sharp one: position in the stream is not causation.
`collectSkillInvocations` flattened every `tool_use` block across every
assistant event into one ordered array, and `chainHit` read position
alone. But the model can emit several `Skill` blocks in a SINGLE assistant
message, and it has not seen the first skill's body when it does — those
are parallel guesses, not a hand-off. A run that emitted `testing` and
`test-servers` together in one message scored as `A` led to `B`, and would
have gone on scoring after the pointer in `testing`'s body was deleted:
exactly the false pass this feature exists to rule out, arriving by a
second route (Copilot).
Each recorded invocation now carries the assistant event it came from —
`{payload, turn}` — and `chainHit` requires every link after the first to
land in a STRICTLY later turn. The turn count is threaded through
`collectSkillInvocations`'s `turnOffset`/`nextTurn` so a stream read in
chunks stays monotonic rather than restarting per read; without that, a
chunk boundary would reset the numbering and reintroduce the same bug on
long runs.
The scan stays greedy, which is still correct under the constraint: taking
the earliest occurrence of a link can only leave more room for the rest,
so no later starting point succeeds where the greedy one fails.
`sampleHit` is deliberately unaffected — a first-move case asks only
whether a skill fired at all, so it reads names and ignores turns.
Four new assertions in one case: a same-turn pair scores false; the same
two skills a turn apart score true; a same-turn pair does not poison a
later genuine hand-off; and only the chain's first link is unconstrained.
Plus a `collectSkillInvocations` case pinning the turn tagging and the
cross-chunk offset.
`docs/skill-authoring.md` now lists this as the second way a chained case
can false-pass, alongside a prompt carrying the target's own trigger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe
Signed-off-by: cliffhall <cliff@futurescale.com>
… rule Re-measured the two committed chain cases under the stricter turn-boundary scoring from 313a580: 100% and 33% at RUNS=3, against 33% and 33% before. That rise is NOT an effect of the change and the doc now says so. The turn rule only ever rejects matches a flatter reading accepted, so it cannot raise a score — the two runs simply straddle a 67-point swing on the same prompt at RUNS=3, where one sample is worth 33 points. Reading it as an improvement would be exactly the mistake the eval exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe Signed-off-by: cliffhall <cliff@futurescale.com>
Correcting my earlier gate noteI attributed the run-2 and run-3 failures to machine contention. A fourth run shows that was only half right, so the earlier comment overstated the case.
Run 4 failed on a quiet machine, in a different stage, with the test itself taking 1.6 s. Contention does not explain that. The honest read is a real timing race that parallelism makes more likely — not merely a slow box — and I have filed it as #2250 (v2 / bug / Todo / High) rather than papering over it. What has not changed is that this PR cannot be the cause, and that is checkable rather than a judgement call: The diff touches Everything else was green across all four runs: lint, format, typecheck, build, the CLI/TUI/launcher suites, coverage thresholds, |
There was a problem hiding this comment.
🟢 Approval recommended
The implementation satisfies #2204 with comprehensive validation, isolation, reporting, and regression coverage.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Review loop complete — two consecutive 🟢 approvals, zero comments
Round 5 was requested deliberately: one zero-comment round is not a stopping condition, since Copilot's inline comments can lag its review body. Two in a row is. Every one of the eight findings was real, and three of them changed what this PR measures rather than just how it reads:
The feature therefore ships having already caught two ways a chained case can lie about itself, both written up in Two issues filed from this work rather than folded in:
|
CI failure is
|
Closes #2204
skills:evalran every case with--max-turns 1, so it measured exactly one thing: whether a skill is the model's first tool call. That is the right measurement for a skill a user reaches directly, and it leaves one class entirely unmeasured — a skill reached from inside another skill.testingopens by telling the model that picking a fixture is/test-serversand that it has to load it;test-serversscores 5/5, and all five of its cases ask for it by name. A skill only ever reachable through that hand-off would score a clean 100% while the hand-off silently never fired.What changed
An eval case is now one of two shapes, and exactly one:
{ "prompt": "…", "expect": "test-servers" } // first move, 1 turn { "prompt": "…", "chain": ["testing", "test-servers"] } // hand-off, 14 turnstesting → test-serversmeasurement undertesting, where atest-serversdescription edit would never be seen.verify:skillsandcollectCasesboth validate in a second pass —test-serverssorts beforetesting, so a per-directory check would reject a live chain as unknown purely because of where the alphabet put it.collectSkillInvocationstherefore returns an ordered array rather than aSet— aB, A, Brun has to stay distinguishable from one that never reached B from A.CHAIN_MAX_TURNS(14) andCHAIN_THRESHOLDas their own knobs.Read-only containment no longer leans on
--max-turns 1, which was doing much of it by itself. The deny list gains the agentic and network tools —Taskin particular, whose subagent the flag does not reach.Measured
skills:eval -- test-servers:So the hand-off is real and unreliable — the fact nothing could observe before this change. All seven first-move cases held at 100%, so the new column displaced nothing.
Two calls that follow from the numbers, both written up in
docs/skill-authoring.md:CHAIN_THRESHOLDdefaults to 0.5, not 0.8. The weakest claim worth asserting is that the pointer is taken more often than not. At 0.8 both committed cases are red no matter how strongly the first skill points at the second, and the column stops carrying signal; at 0.5 the difference between 67% and 33% is the signal.skills:evalis not a gate, so a hand-off case below threshold is a signal to investigate the pointer's strength, not a build break.["pr-flow", "test-servers"]probe measured 0% and was dropped rather than committed.pr-flowsays nothing about test fixtures, so the case had no lever short of broadening a description onto another skill's ground. The doc now says to confirm the first link's body actually points at the target before writing a chained case.Acceptance
testing→test-servershand-off.docs/skill-authoring.mdgains a section on when to write a chained case instead of a direct one.Testing
npm run test:scripts— 512 pass (23 new cases acrossskill-eval.test.mjs,skill-manifest.test.mjs,verify-skills.main.test.mjs), covering the subsequence semantics, the ordered-array change, the chain validator's five rules, the wider turn budget, the deny list, and the two-pass ordering trap.npm run verify:skills— OK, listing unchanged at 3234/4000.npm run local:gate— exit 0: 326 + 27 + 27 + 1 test files green, coverage 98.64 / 98.96 / 96.32 / 100 across web, cli, tui, launcher, all smokes OK, Storybook 514 tests passed.🤖 Generated with Claude Code
https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe