Skill routing · runnable eval

Grade skill routing
yourself.

The runnable pack behind the walkthrough The Skill That Never Fired. Two skills that could both answer a request, twelve labelled prompts, and a reader that grades which skill actually fired, read from the Skill tool call in Claude Code’s event stream. It measures recall, false triggers and the number a one-skill trigger test can’t give you: interference, which skill won when a neighbour that could answer the same request was installed too.

Needs the claude CLI (a paid Claude plan or an API key) and jq Default run: 36 Claude Code sessions, 5 to 7 minutes Re-run 7 Oct 2026 on Claude Code 2.1.267 · Claude Opus 5
The whole pack (.zip) Read the walkthrough
Quickstart
unzip skill-routing-eval.zip && cd skill-routing-eval

OUT=routing-$(date +%F).txt ./run.sh; echo "exit $?"       # the sharp pair: the clean control
SKILLS=skills-vague ./run.sh prompts-vague.tsv; echo "exit $?"   # the vague pair: the failing case
SKILLS=~/my-skills OUT=routing-mine-$(date +%F).txt ./run.sh my-prompts.tsv   # your own

run.sh copies the skills into a throwaway project made with mktemp and deletes it afterwards, so nothing lands in ~/.claude/skills. Your personal and plugin skills still load and count as neighbours; a skill under test that shares a name with one in ~/.claude/skills stops the run, because the personal copy would win. Each row names the skills that fired, so a wrong shows which neighbour took the request. OUT keeps the output in a file: its first lines record the date, CLI version, prompt file, skills and repeats, and its last lines the model the CLI reported and the gate result. The exit status is the release gate: 0 pass, 1 fail, 2 could not grade (logged out, out of usage, an unreadable Skill call, or a bad row in the prompt file, which is checked before any session starts). If you pipe into tee instead of using OUT, run set -o pipefail first, or the pipe reports tee’s status, so a failed gate can still exit 0. Defaults: REPEATS=3, MIN_RECALL_PCT=90, MAX_FALSE=0, MAX_INTERFERENCE=0; set MODEL to pin the model.

What one run shows · 7 Oct 2026

PairRecallFalse triggersInterference
Sharp paircustomer-date, export-date8/80/40/8
Vague pairlong-date, slash-date6/81/41/8

One run of each pair (REPEATS=1) on Claude Code 2.1.267 and Claude Opus 5, as of 7 Oct 2026. With the vague pair, “Format the date for the database file” went to long-date, “Prepare this date for a data export” fired nothing, and “Parse this log timestamp” fired long-date. That run had no double-fire; the walkthrough’s run on 31 Aug 2026 had two. The vague run took 4 minutes for 12 sessions, because Claude stopped to read both look-alike skills. It exited 1: the gate failed. The output below is that run’s recorded event stream graded again by the current run.sh, which adds the FIRED column, so its first line shows the time of grading.

run.sh output · vague pairRECORDED 7 OCT 2026 20:21 · RE-GRADED BY THIS RUN.SH · PATH SHORTENED
skill routing eval · 2026-10-07 22:16 BST · 2.1.267 (Claude Code)
prompts: …/skill-routing-eval/prompts-vague.tsv
skills:  long-date slash-date (copied into a temp project)
repeats: 1 · model requested: CLI default

EXPECTED                  OVER 1 RUNS                           FIRED (RUNS)                          PROMPT
------------------------------------------------------------------------------------------------------------------------------
long-date                 right 1  wrong 0  none 0  both 0      long-date (1)                         Rewrite this date for the customer email: 2026-08-30
long-date                 right 1  wrong 0  none 0  both 0      long-date (1)                         Put this date in a letter to the client: 2026-08-30
long-date                 right 1  wrong 0  none 0  both 0      long-date (1)                         Format the date for a message to a customer: 2026-08-30
long-date                 right 1  wrong 0  none 0  both 0      long-date (1)                         Tidy the date in this customer-facing note: 2026-08-30
slash-date                right 1  wrong 0  none 0  both 0      slash-date (1)                        Format this date for the CSV export: 2026-08-30
slash-date                right 1  wrong 0  none 0  both 0      slash-date (1)                        Put this date into the spreadsheet export: 2026-08-30
slash-date                right 0  wrong 1  none 0  both 0      long-date (1)                         Format the date for the database file: 2026-08-30
slash-date                right 0  wrong 0  none 1  both 0      -                                     Prepare this date for a data export: 2026-08-30
none                      quiet 1  fired 0                      -                                     What is today's date?
none                      quiet 1  fired 0                      -                                     When did the Second World War end?
none                      quiet 0  fired 1                      long-date (1)                         Parse this log timestamp: 2026-08-30T14:22Z
none                      quiet 1  fired 0                      -                                     What day of the week is 2026-08-30?

recall        6 / 8   (should-fire runs that fired the right skill)
false trigger 1 / 4   (should-fire-nothing runs that fired a skill anyway)
interference  1 / 8   (a non-intended skill fired on a run meant for another)
model used:   claude-opus-5[1m]

A skill that scores well alone and badly here has a selection problem, and editing its body will not touch it.
GATE FAIL  recall below 90%
GATE FAIL  false triggers above 0
GATE FAIL  interference above 0
2×2 · what distinguishes the skillsTHE DURABILITY CURVE
The walkthrough’s two-by-two from one run on Claude Opus 5, 31 Aug 2026: name draws the line by description draws the line. Three corners routed eight of eight to the right skill; the corner where neither field distinguishes the skills had one miss and two double-fires.

The walkthrough’s 2×2, from one run on 31 Aug 2026 (Claude Opus 5), eight prompts per cell. Sharp name and sharp description: 8 of 8 routed right. Sharp name with the vague description: 8 of 8. Format-only name with a sharp description: 8 of 8. Format-only name with the vague description: customer prompts 3 right and 1 miss, export prompts 2 right and 2 fired both. The cell with sharp names and the vague description is an easy case, because six of its eight prompts contain the word customer or export from the names.

customer-date · SKILL.md

Download

The skill that should fire on customer requests. Its description draws the boundary against its neighbour: “For CSV or data exports, use export-date.” That one line is what the router reads.

Saving it by hand: Claude Code finds a skill only at <name>/SKILL.md, so save this as skills/customer-date/SKILL.md. The zip already has the folders.

customer-date.SKILL.mdMARKDOWN · 6 LINES
---
name: customer-date
description: Format a date for customer-facing UK correspondence (emails, letters, messages to customers) as D Month YYYY. For CSV or data exports, use export-date.
---

Rewrite the date the user gives in UK long form, for example 30 August 2026. Reply with only the formatted date.

export-date · SKILL.md

Download

The neighbour. It shares the verb, format a date, for a different job, and its description draws the line back the other way. Installed next to customer-date, it is the skill that can quietly take a request meant for the other one.

Saving it by hand: save this as skills/export-date/SKILL.md.

export-date.SKILL.mdMARKDOWN · 6 LINES
---
name: export-date
description: Format a date for CSV or database exports (spreadsheets, data files) as DD/MM/YYYY. For customer emails and letters, use customer-date.
---

Rewrite the date the user gives in slashed form, for example 30/08/2026. Reply with only the formatted date.

prompts.tsv

Download

The ground truth. Twelve labelled requests: four that want the customer skill, four the export skill, four that should fire neither. Column one is the known-correct skill, so edit it to point at your own skills.

prompts.tsvTSV · 16 LINES
# Ground-truth prompt set for the routing eval.
# Format: <expected-skill><TAB><prompt>. Use "none" when the correct answer is that no skill fires.
# To test your own skills, put the skill you expect in column 1 and the request in column 2.
# Two skills is the floor. Add the real neighbours your skill may compete with.
customer-date	Rewrite this date for the customer email: 2026-08-30
customer-date	Put this date in a letter to the client: 2026-08-30
customer-date	Format the date for a message to a customer: 2026-08-30
customer-date	Tidy the date in this customer-facing note: 2026-08-30
export-date	Format this date for the CSV export: 2026-08-30
export-date	Put this date into the spreadsheet export: 2026-08-30
export-date	Format the date for the database file: 2026-08-30
export-date	Prepare this date for a data export: 2026-08-30
none	What is today's date?
none	When did the Second World War end?
none	Parse this log timestamp: 2026-08-30T14:22Z
none	What day of the week is 2026-08-30?

long-date · SKILL.md

Download

The failing pair, part one. The same body as customer-date, but a name that only says the output format and the description “Format a date.”, which it shares with slash-date. Nothing in the metadata tells Claude which one a customer request wants.

Saving it by hand: save this as skills-vague/long-date/SKILL.md.

long-date.SKILL.mdMARKDOWN · 6 LINES
---
name: long-date
description: Format a date.
---

Rewrite the date the user gives in UK long form, for example 30 August 2026. Reply with only the formatted date.

slash-date · SKILL.md

Download

Part two: the same body as export-date and the same one-line description. Run the pair with SKILLS=skills-vague ./run.sh prompts-vague.tsv to watch routing go wrong.

Saving it by hand: save this as skills-vague/slash-date/SKILL.md.

slash-date.SKILL.mdMARKDOWN · 6 LINES
---
name: slash-date
description: Format a date.
---

Rewrite the date the user gives in slashed form, for example 30/08/2026. Reply with only the formatted date.

prompts-vague.tsv

Download

The same twelve requests, relabelled for the vague pair: customer wording wants long-date, export wording wants slash-date, and four should fire neither.

prompts-vague.tsvTSV · 16 LINES
# The failing pair: long-date and slash-date share one vague description, "Format a date."
# Same requests and same ground truth as prompts.tsv: customer wording wants the long form,
# export wording wants the slashed form, and the last four should fire neither skill.
# Run it with:  SKILLS=skills-vague ./run.sh prompts-vague.tsv
long-date	Rewrite this date for the customer email: 2026-08-30
long-date	Put this date in a letter to the client: 2026-08-30
long-date	Format the date for a message to a customer: 2026-08-30
long-date	Tidy the date in this customer-facing note: 2026-08-30
slash-date	Format this date for the CSV export: 2026-08-30
slash-date	Put this date into the spreadsheet export: 2026-08-30
slash-date	Format the date for the database file: 2026-08-30
slash-date	Prepare this date for a data export: 2026-08-30
none	What is today's date?
none	When did the Second World War end?
none	Parse this log timestamp: 2026-08-30T14:22Z
none	What day of the week is 2026-08-30?

run.sh

Download

The reader and the gate. It copies the skills into a throwaway project, runs each prompt through Claude Code, reads which skill the Skill tool actually fired from the event stream, marks it right / wrong / none / both, and names the skills that fired. It rolls those up into recall, false triggers and interference, stops with exit 2 if the CLI fails or the prompt file has a bad row, and exits 1 when a threshold is crossed.

run.shBASH · 290 LINES
#!/usr/bin/env bash
# run.sh: grade skill ROUTING (selection) against a labelled prompt set.
#
# For each labelled prompt it runs Claude Code non-interactively, reads which skill the
# `Skill` tool actually fired from the event stream, and marks the result against the
# known-correct skill: right / wrong / none / both. Each row also names the skills that
# fired, so a `wrong` shows which neighbour took the request. Then it tallies three
# numbers: recall, false triggers and interference.
#
# Interference is the number a one-skill trigger rate cannot give you: which skill won
# when a neighbour that could answer the same request was installed as well.
#
# Routing is a model decision that can scatter, so each prompt runs REPEATS times
# (default 3) and every score is a rate over all runs. REPEATS=1 gives a quick look.
#
# Usage, from the unzipped folder:
#   OUT=routing-$(date +%F).txt ./run.sh                   demo: the sharp pair in skills/
#   SKILLS=skills-vague ./run.sh prompts-vague.tsv          the failing pair, same requests
#   SKILLS=~/my-skills OUT=mine.txt ./run.sh my-prompts.tsv your own skill folders + labelled prompts
#   SKILLS=installed ./run.sh my-prompts.tsv                grade what is already installed where you run it
#
# OUT=<file> writes the output to that file as well as the screen and keeps the exit status.
# (Piping into tee reports tee's status instead, unless you run `set -o pipefail` first.)
#
# SKILLS is a folder of skill folders (<name>/SKILL.md). They are copied into a throwaway
# project made with mktemp and Claude runs there, so nothing is written to ~/.claude/skills.
# Your personal and plugin skills still load, as they do in real use, and count as neighbours.
# A skill under test with the same name as one in ~/.claude/skills would be shadowed by the
# personal copy, so run.sh stops if it finds one.
#
# Every row of the prompt file is checked before any Claude session starts: a TAB between
# label and prompt, no spaces in the label, and (when SKILLS is a folder) a label that is
# "none" or one of the skills being tested.
#
# Settings: REPEATS (3) · MODEL (your CLI default) · OUT (none) · MIN_RECALL_PCT (90)
#           MAX_FALSE (0) · MAX_INTERFERENCE (0)
# Exit status: 0 gate passed · 1 gate failed · 2 the run could not be graded or OUT could not be written
# Needs: claude (logged in) and jq. Cost: one Claude Code session per prompt per repeat.
set -uo pipefail

here="$(cd "$(dirname "$0")" && pwd)"
PROMPTS="${1:-$here/prompts.tsv}"
REPEATS="${REPEATS:-3}"
MODEL="${MODEL:-}"
OUT="${OUT:-}"
SKILLS="${SKILLS:-$here/skills}"
MIN_RECALL_PCT="${MIN_RECALL_PCT:-90}"
MAX_FALSE="${MAX_FALSE:-0}"
MAX_INTERFERENCE="${MAX_INTERFERENCE:-0}"

die() { echo "run.sh: $*" >&2; exit 2; }
command -v claude >/dev/null || die "need the 'claude' CLI on PATH"
command -v jq >/dev/null     || die "need 'jq' on PATH"
# a number above 999999999 (leading zeros aside) can wrap round in shell arithmetic, so refuse it before any sum
big='^0*[1-9][0-9]{9,}$'
[[ "$REPEATS" =~ $big ]]          && die "REPEATS is too large (the most is 999999999)"
[[ "$MAX_FALSE" =~ $big ]]        && die "MAX_FALSE is too large (the most is 999999999)"
[[ "$MAX_INTERFERENCE" =~ $big ]] && die "MAX_INTERFERENCE is too large (the most is 999999999)"
[[ "$REPEATS" =~ ^[0-9]+$ ]] && (( 10#$REPEATS >= 1 )) || die "REPEATS must be a whole number of 1 or more"
[[ "$MIN_RECALL_PCT" =~ ^[0-9]+$ && ! "$MIN_RECALL_PCT" =~ $big ]] && (( 10#$MIN_RECALL_PCT <= 100 )) \
  || die "MIN_RECALL_PCT must be a whole number from 0 to 100"
[[ "$MAX_FALSE" =~ ^[0-9]+$ ]]        || die "MAX_FALSE must be a whole number"
[[ "$MAX_INTERFERENCE" =~ ^[0-9]+$ ]] || die "MAX_INTERFERENCE must be a whole number"
# read every number as base 10, so a leading zero (08) is not taken as octal
REPEATS=$((10#$REPEATS)); MIN_RECALL_PCT=$((10#$MIN_RECALL_PCT))
MAX_FALSE=$((10#$MAX_FALSE)); MAX_INTERFERENCE=$((10#$MAX_INTERFERENCE))
[[ "$PROMPTS" == /* ]] || PROMPTS="$PWD/$PROMPTS"
[[ -r "$PROMPTS" ]] || die "cannot read the prompt file $PROMPTS"
if [[ -n "$OUT" ]]; then
  [[ "$OUT" == /* ]] || OUT="$PWD/$OUT"
  [[ "$OUT" != */ && ! -d "$OUT" ]] || die "cannot write OUT=$OUT (OUT must be a file, and this is a folder)"
  [[ -d "$(dirname "$OUT")" && -w "$(dirname "$OUT")" ]] || die "cannot write OUT=$OUT (its folder is missing or read-only)"
  [[ ! -e "$OUT" || -w "$OUT" ]] || die "cannot write OUT=$OUT"
fi

skill_names_in() {  # the folder names and frontmatter names of the skills under $1 (any depth up to $2)
  find -L "$1" -mindepth 2 -maxdepth "$2" -name SKILL.md 2>/dev/null | while IFS= read -r f; do
    basename "$(dirname "$f")"
    awk 'NR==1{if($0!~/^---/)exit;next} /^---/{exit} /^name:/{sub(/^name:[ \t]*/,"");sub(/[ \t\r]+$/,"");print;exit}' "$f"
  done | grep -v '^$' | sort -u
}

# scratch space for this run; removed on exit, whatever happens
work="$(mktemp -d "${TMPDIR:-/tmp}/skill-routing-eval.XXXXXX")" || die "mktemp failed"
trap 'rm -rf "$work"' EXIT

personal="$(skill_names_in "$HOME/.claude/skills" 4)"
if [[ "$SKILLS" == "installed" ]]; then
  skills_label="already installed in $PWD"
  allowed=""
  known="$( { skill_names_in "$PWD/.claude/skills" 2; printf '%s\n' "$personal"; } | sort -u)"
else
  [[ "$SKILLS" == /* ]] || SKILLS="$PWD/$SKILLS"
  ls "$SKILLS"/*/SKILL.md >/dev/null 2>&1 \
    || die "no <name>/SKILL.md folders in $SKILLS (download the zip, or point SKILLS at your skills)"
  testing="$(skill_names_in "$SKILLS" 2)"
  clash="$(comm -12 <(printf '%s\n' "$testing") <(printf '%s\n' "$personal" | sort -u) | grep -v '^$')"
  if [[ -n "$clash" ]]; then
    die "$(echo $clash) is also installed in ~/.claude/skills. Claude Code loads a personal skill \
over a project skill with the same name, so the copy under test would be shadowed. Move the \
personal copy out of ~/.claude/skills or rename the skill under test, or run with SKILLS=installed \
to grade the installed one."
  fi
  allowed="$(printf '%s\nnone\n' "$testing")"
  known=""
  mkdir -p "$work/project/.claude/skills"
  cp -R "$SKILLS"/. "$work/project/.claude/skills/"
  skills_label="$(cd "$SKILLS"/ && ls -d */ | tr -d / | tr '\n' ' ')(copied into a temp project)"
fi

# check every row before any Claude session starts
TAB=$'\t'; CR=$'\r'
labels=(); prompts=(); bad=(); notes=(); lineno=0
while IFS= read -r line || [[ -n "$line" ]]; do
  lineno=$((lineno+1))
  line=${line%${CR}}
  [[ -z "${line//[[:space:]]/}" || "${line:0:1}" == "#" ]] && continue
  if [[ $line != *${TAB}* ]]; then
    bad+=("line $lineno: no TAB between the label and the prompt"); continue; fi
  label=${line%%${TAB}*}; prompt=${line#*${TAB}}
  while [[ "${prompt:0:1}" == "$TAB" ]]; do prompt=${prompt:1}; done
  if [[ -z "$label" || "$label" =~ [[:space:]] ]]; then
    bad+=("line $lineno: the label '$label' is empty or contains a space"); continue; fi
  if [[ -z "${prompt//[[:space:]]/}" ]]; then
    bad+=("line $lineno: no prompt after the TAB"); continue; fi
  if [[ -n "$allowed" ]] && ! grep -qxF -- "$label" <<<"$allowed"; then
    bad+=("line $lineno: '$label' is neither 'none' nor a skill in $SKILLS"); continue; fi
  if [[ -n "$known" && "$label" != "none" && "$label" != *:* ]] && ! grep -qxF -- "$label" <<<"$known"; then
    note="note: '$label' is not in ./.claude/skills or ~/.claude/skills; fine for a plugin or managed skill, otherwise check the spelling"
    [[ " ${notes[*]:-} " == *"$note"* ]] || notes+=("$note"); fi
  labels+=("$label"); prompts+=("$prompt")
done < "$PROMPTS"
if (( ${#bad[@]} > 0 )); then
  { echo "run.sh: ${#bad[@]} bad row(s) in $PROMPTS; nothing was run:"; printf '  %s\n' "${bad[@]}"; } >&2
  exit 2
fi
(( ${#labels[@]} > 0 )) || die "no rows read from $PROMPTS (each row is <skill><TAB><prompt>)"
[[ "$SKILLS" == "installed" ]] || cd "$work/project" || die "cannot enter the temp project"

fired_skills() {  # one line per skill the Skill tool fired for this prompt (deduped), or __ERROR__
  local out names
  out=$(claude -p "$1" ${MODEL:+--model "$MODEL"} --output-format stream-json --verbose \
          --no-session-persistence </dev/null 2>>"$work/stderr.log")
  if [[ $? -ne 0 ]] || ! jq -e 'select(.type=="result") | (.is_error | not)' <<<"$out" >/dev/null 2>&1; then
    jq -r 'select(.type=="result") | .result // empty' <<<"$out" 2>/dev/null | head -1 > "$work/error"
    [[ -s "$work/error" ]] || tail -1 "$work/stderr.log" > "$work/error" 2>/dev/null
    echo "__ERROR__"; return
  fi
  [[ -s "$work/model" ]] || jq -r 'select(.type=="system" and .subtype=="init") | .model // empty' \
                              <<<"$out" > "$work/model" 2>/dev/null
  # A skill fired when the stream holds a Skill call for it and that call's result is not an error.
  #   the call:   an assistant event, a message.content item with type "tool_use" and name "Skill";
  #               the skill is its input.skill and the call is known by its id
  #   its result: a user event, a message.content item with type "tool_result" whose tool_use_id
  #               is the call's id; is_error true means the call failed and no skill was loaded
  # A call answered with an error does not count as fired. A call with no result means the stream
  # is cut short, and an assistant event whose content is not a list, or a content item (assistant or
  # user) that is not an object with a type, a result whose is_error is not true or false, or two
  # Skill calls with the same id, is a shape this reader does not know: all of these stop the
  # run, since passing over them would score the prompt as
  # "nothing fired". The stream is read whole (jq -s) so each call can be matched with its result.
  names=$(jq -rs 'try (
      [ .[] | select(.type=="user") | .message.content
        | if type=="array" then .[] else empty end
        | if type!="object" or (.type|type)!="string" then error("an item is not a typed object") else . end
        | select(.type=="tool_result") ] as $results
      | if ($results | map(select(has("is_error") and (.is_error | type) != "boolean")) | length) > 0
        then error("is_error is not true or false") else . end
      | [ .[] | select(.type=="assistant") | .message.content | if type=="array" then .[] else empty end
          | select(type=="object" and .type=="tool_use" and .name=="Skill") | .id ] as $ids
      | if ($ids | length) != ($ids | unique | length) then error("two Skill calls share an id") else . end
      | .[] | select(.type=="assistant") | .message.content
      | if type=="array" then .[] else error("content is not a list") end
      | if type!="object" or (.type|type)!="string" then error("an item is not a typed object") else . end
      | select(.type=="tool_use" and .name=="Skill") | . as $call
      | [ $results[] | select(.tool_use_id == $call.id) ] as $r
      | if ($call.input.skill | type=="string" and length>0 | not) then "__NOSKILL__"
        elif ($call.id | type) != "string" or ($r | length) == 0 then "__NORESULT__"
        elif ($r | map(select(.is_error == true)) | length) > 0 then empty
        else $call.input.skill end )
    catch "__UNREAD__"' <<<"$out") || names="__UNREAD__"
  if grep -qx '__UNREAD__' <<<"$names"; then
    echo "an event in the stream has a shape run.sh cannot read, so the event stream's format may have changed (see README: read one decision by hand)" > "$work/error"
    echo "__ERROR__"; return
  fi
  if grep -qx '__NOSKILL__' <<<"$names"; then
    echo "a Skill tool call carried no input.skill, so the event stream's format may have changed (see README: read one decision by hand)" > "$work/error"
    echo "__ERROR__"; return
  fi
  if grep -qx '__NORESULT__' <<<"$names"; then
    echo "a Skill tool call has no result in the stream, so the stream may be cut short or its format may have changed (see README: read one decision by hand)" > "$work/error"
    echo "__ERROR__"; return
  fi
  [[ -n "$names" ]] && sort -u <<<"$names"
}

main() {
  local sf_runs=0 recalled=0 nf_runs=0 false_trig=0 interference=0 i r n hit f gate
  echo "skill routing eval · $(date '+%Y-%m-%d %H:%M %Z') · $(claude --version </dev/null 2>/dev/null | head -1)"
  echo "prompts: $PROMPTS"
  echo "skills:  $skills_label"
  echo "repeats: $REPEATS · model requested: ${MODEL:-CLI default}"
  (( ${#notes[@]} > 0 )) && printf '%s\n' "${notes[@]}"
  echo
  printf '%-24s  %-36s  %-36s  %s\n' "EXPECTED" "OVER $REPEATS RUNS" "FIRED (RUNS)" "PROMPT"
  printf '%s\n' "------------------------------------------------------------------------------------------------------------------------------"

  for ((i=0; i<${#labels[@]}; i++)); do
    local expected="${labels[i]}" prompt="${prompts[i]}" allfired=""
    local t_right=0 t_wrong=0 t_none=0 t_both=0 t_ok=0 t_ft=0
    for ((r=0; r<REPEATS; r++)); do
      fired=()
      while IFS= read -r line; do
        [[ -n "$line" ]] && fired+=("$line")
      done < <(fired_skills "$prompt")
      if [[ "${fired[0]:-}" == "__ERROR__" ]]; then
        echo
        echo "run.sh could not grade: $prompt"
        echo "  $(cat "$work/error" 2>/dev/null)"
        echo "Nothing from this run is scored. For a login or usage message, run claude and /login, or wait for usage to reset."
        exit 2
      fi
      n=${#fired[@]}
      (( n > 0 )) && allfired+="$(printf '%s\n' "${fired[@]}")"$'\n'
      if [[ "$expected" == "none" ]]; then
        nf_runs=$((nf_runs+1))
        if (( n == 0 )); then t_ok=$((t_ok+1)); else t_ft=$((t_ft+1)); false_trig=$((false_trig+1)); fi
      else
        sf_runs=$((sf_runs+1))
        # Skills copied from a folder are local, so only the exact name is the skill under test: a
        # plugin's plugin:name with the same name is a neighbour. With SKILLS=installed the expected
        # skill may be a plugin's, which fires as plugin:name, so either spelling in column 1 matches.
        hit=0
        for f in "${fired[@]:-}"; do
          if [[ "$f" == "$expected" ]] || [[ "$SKILLS" == "installed" && "$f" == *":$expected" ]]; then hit=1; fi
        done
        if   (( n == 0 ));             then t_none=$((t_none+1))
        elif (( hit == 1 && n == 1 )); then t_right=$((t_right+1)); recalled=$((recalled+1))
        elif (( hit == 1 && n >= 2 )); then t_both=$((t_both+1));  recalled=$((recalled+1)); interference=$((interference+1))
        else                                t_wrong=$((t_wrong+1)); interference=$((interference+1)); fi
      fi
    done

    if [[ "$expected" == "none" ]]; then
      dist="quiet ${t_ok}  fired ${t_ft}"
    else
      dist="right ${t_right}  wrong ${t_wrong}  none ${t_none}  both ${t_both}"
    fi
    firedcol="$(printf '%s' "$allfired" | grep -v '^$' | sort | uniq -c \
                | awk '{printf "%s%s (%s)", (NR>1 ? ", " : ""), $2, $1}')"
    printf '%-24s  %-36s  %-36s  %s\n' "$expected" "$dist" "${firedcol:--}" "$prompt"
  done

  echo
  echo "recall        $recalled / $sf_runs   (should-fire runs that fired the right skill)"
  echo "false trigger $false_trig / $nf_runs   (should-fire-nothing runs that fired a skill anyway)"
  echo "interference  $interference / $sf_runs   (a non-intended skill fired on a run meant for another)"
  echo "model used:   $(cat "$work/model" 2>/dev/null)"
  echo
  echo "A skill that scores well alone and badly here has a selection problem, and editing its body will not touch it."

  # release gate: a non-zero exit when routing crosses the thresholds above
  gate=0
  if (( sf_runs > 0 && recalled * 100 < MIN_RECALL_PCT * sf_runs )); then
    echo "GATE FAIL  recall below ${MIN_RECALL_PCT}%"; gate=1; fi
  if (( false_trig > MAX_FALSE )); then
    echo "GATE FAIL  false triggers above $MAX_FALSE"; gate=1; fi
  if (( interference > MAX_INTERFERENCE )); then
    echo "GATE FAIL  interference above $MAX_INTERFERENCE"; gate=1; fi
  # a pass names only what was tested: a measure with no rows behind it cannot have met its threshold
  if (( gate == 0 )); then
    if (( sf_runs == 0 )); then
      echo "GATE PASS  false triggers <= $MAX_FALSE (recall and interference not tested: no row expects a skill)"
    elif (( nf_runs == 0 )); then
      echo "GATE PASS  recall >= ${MIN_RECALL_PCT}%, interference <= $MAX_INTERFERENCE (false triggers not tested: no row is labelled none)"
    else
      echo "GATE PASS  recall >= ${MIN_RECALL_PCT}%, false triggers <= $MAX_FALSE, interference <= $MAX_INTERFERENCE"
    fi
  fi
  return $gate
}

if [[ -n "$OUT" ]]; then
  main | tee "$OUT"
  codes=("${PIPESTATUS[@]}")
  (( codes[1] == 0 )) || die "could not write OUT=$OUT, so that file is missing or incomplete"
  exit "${codes[0]}"
fi
main
exit $?

Subscribe to get the next structural lens in your inbox.

Free. New tools and the essays behind them, with every source shown. The button opens Substack; nothing on this page goes with it.

Subscribe free