Sprag

Delegate repeat Claude Code work to Haiku and Sonnet subagents with route-scan

sprag route-scan reads your Claude Code session logs after the fact, finds request types an expensive model handled repeatedly, and writes delegation rules that send that work to cheaper subagents. Savings are recorded per run as a ledger, not an estimate.

티어 기준·리서치 근거는 TIER_CRITERIA.md 참고. 이 문서는 룰 파일 구조·스캔 트리거·사전 준비 등 운용 상세를 다룬다.

모델 피팅 래칫 — 사용자 룰과 파일부터 분리, 로그 기반 자동 갱신

승격된 위임 룰은 손으로 쓴 래칫 룰과 섞이지 않도록 별도 파일에 저장됩니다. 프로젝트는 .claude/ratchet-model.md, 글로벌은 ~/.claude/ratchet-model.md. 이 파일은 전적으로 도구 소유라 매 스캔마다 통째로 재생성되며, 이후에도 살아 움직입니다:

동작 구조 — 실시간 라우팅이 아니라 세션 경계 캘리브레이션

  1. install 시 SessionStart 훅이 등록되어, 새 세션 시작·/clear 때 캐시된 스캔 결과를 세션 컨텍스트로 주입합니다. 재스캔은 시간이 아니라 데이터가 트리거: 마지막 스캔 이후 새 transcript가 ~5MB 이상 쌓이면 즉시, 소량이면 하루 1회, 아무 변화가 없으면 아예 돌지 않습니다 (변화 없는 재스캔은 결과가 동일하므로). 최소 간격 1시간 가드 포함, 룰 등록(promote) 직후에는 통계 기준선 확보를 위해 즉시 1회.

위임이 실제로 실행되면 이 게이트를 건너뛰고 바로 재스캔합니다. PostToolUse 훅이 Task·Agent 호출을 받아 백그라운드로 스캔을 띄우므로, 🔀 Routing saved 금액이 다음 렌더에 반영됩니다. 게이트는 "결과가 같을 스캔을 건너뛴다"는 취지인데 위임은 결과가 반드시 달라지는 사건이고, 위임 한 건의 로그는 5MB에 한참 못 미쳐서 그냥 두면 최소 1시간에서 최대 하루를 기다려야 했습니다. 스캔은 실측 0.5초(14일·67MB 기준), 위임은 하루 평균 1.6회라서 추가 비용이 하루 1초 미만입니다. 병렬 위임이 한꺼번에 끝나도 30초 하한 때문에 스캔은 한 번만 돕니다.

  1. 반복(≥3회) 패턴이 있으면 statusline에 🅷⚠ route? R1 칩이 뜨고, Claude가 등록 여부와 scope(--project/--global)를 물어봅니다.
  2. 등록된 룰은 다음 세션부터 메인 모델이 해당 유형을 haiku/sonnet 서브에이전트로 자동 위임하게 합니다.

권장 사전 준비

~/.claude/agents/model: haiku 서브에이전트(예: haiku-explore / haiku-runner / haiku-translate)와 model: sonnet 범용 서브에이전트를 만들어 두면 룰이 바로 실행 가능해집니다.


route-scan internals (English)

Tier criteria and research evidence: TIER_CRITERIA.md.

Model-fitting ratchet — a separate file, continuously refreshed

Promoted delegation rules never mix with hand-written ratchet rules: they live in a separate, fully tool-owned file.claude/ratchet-model.md per project, ~/.claude/ratchet-model.md for global scope — regenerated wholesale on every scan, and they stay alive afterward:

How it works — session-boundary calibration, NOT a real-time router

  1. install registers a SessionStart hook that injects cached scan results as session context on startup and /clear. Rescans are data-triggered, not time-triggered: ~5MB of new transcripts rescans immediately, a small trickle rescans daily, no change means no rescan. A 1-hour minimum-interval guard applies; promoting a rule triggers one immediate refresh to establish its stat baseline.

A delegation that actually ran skips that gate and rescans on the spot. A PostToolUse hook on Task/Agent kicks a detached scan, so the 🔀 Routing saved figure is current on the next render. The gate is there to skip scans that would reproduce the same numbers, and a delegation is the one event guaranteed to change them — while its own transcript is nowhere near 5MB, so leaving it to the gate meant waiting an hour at best and a day at worst. Measured: a scan is ~0.5s over 14 days and 67MB, and delegations run ~1.6×/day, so this costs well under a second a day. A 30-second floor keeps a fan-out of parallel agents to a single scan.

  1. When a recurring (≥3×) pattern exists, the statusline shows a 🅷⚠ route? R1 chip and Claude asks whether to register it, and at which scope (--project/--global).
  2. Promoted rules make the main model delegate that work type to a haiku/sonnet subagent automatically from the next session on.

Create model: haiku subagents under ~/.claude/agents/ (e.g. haiku-explore / haiku-runner / haiku-translate) plus a model: sonnet general worker so the rules are immediately actionable.


From the README (English)

🔀 route-scan — "this recurring task could run on a cheaper tier"

Finds the easy work your expensive model (opus/fable) keeps redoing in your session logs and proposes haiku/sonnet delegation rules. Fully local, zero token cost.

Three design pillars:

  1. Difficulty is judged by outcome, not text guessing — tool errors, mutating tool calls, output tokens
  2. Thresholds auto-calibrate to your own 14-day distribution — fixed constants drift with workload
  3. Promoted rules live in a tool-owned file (.claude/ratchet-model.md) that refreshes itself every scan, and a ⚠ rule-health flag fires when a delegated category's error rate climbs — rules report their own staleness
sprag route-scan                    # scan (24h cache) + tiered candidates
sprag harness promote R1 --project  # promote candidate R1 to a model-fitting rule
sprag route-scan dismiss 1          # not interested — won't resurface
sprag route-scan rules              # list model-fitting rules (rm <N> to remove)
sprag route-scan savings            # the savings ledger — which rule moved work off which model, onto which

Dig deeper: tier criteria & research evidencedocs/TIER_CRITERIA.md (Korean) · rule-file mechanics, scan triggers, subagent setupdocs/ROUTE_SCAN.md (Korean + English)

Behind a gateway (Bedrock / LiteLLM)

Through a corporate gateway the transcript records an inference-profile ARN where the model id belongs. That string says nothing about opus or haiku, so older versions read every session as Sonnet — which made T1 (→sonnet) rules unreachable and zeroed the savings figures.

The mapping now resolves in three steps, cheapest first:

  1. LiteLLM gateway, /model/info reachable. The gateway already knows which profile id or ARN backs which model name, so nothing needs to be learned. sprag profile-map prints the cached mapping (gateway address, deployment count, one <key> → <alias> line per entry); sprag profile-map --refresh pulls a fresh copy from GET /model/info and writes only the gateway section of profile-map.json. The refresh runs in the same child process and on the same cadence as the LiteLLM budget gauge: a 5-minute check, and a 24-hour TTL on the cached map. Keeping it current costs no extra call.
  2. Gateway without that route, or /model/info blocked. This is the mapping v3.10.0 introduced and nothing about it changed: the profile id is mapped back to a role (main, opus, sonnet, haiku) and then to the alias your ANTHROPIC_DEFAULT_*_MODEL variables declare, learned by joining each parent Task call to the subagent run it spawned via toolUseId. Below three observations, or when the role votes agree less than 80% of the time, the id stays unknown and drops out of the delegation aggregate rather than being guessed at.
  3. Neither resolves it. Write the mapping yourself in <userDataDir>/profile-map.json. Account id and region may be wildcarded:
{
  "modelAliases": {
    "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5",
    "prod-large": "claude-opus-5",   // house aliases map the same way
    "team-*": "claude-haiku-4-5"
  }
}

Map house aliases that carry no family name (prod-large, team-fast) here too — sprag profile-map lists any key it could not resolve from the gateway, so you know which ones still need a manual line. Shapes that keep the family name are recognized as-is — Bedrock (anthropic.claude-opus-4-5-v1:0), Vertex (claude-opus-4-5@20251101), and the 1M suffix (claude-sonnet-4-5[1m]) — but an alias without one cannot be priced. Rather than report a wrong figure, routing-savings drops those runs from the aggregate (both sides of the comparison must be recognizable); one line in the table above brings them back.

Sprag never stores your auth token. Step 1's refresh reads it from apiKeyHelper at call time and spends it on that one /model/info request only; the token's TTL already belongs to the helper, and a cached copy inside sprag would drift out of sync with it. profile-map.json holds only profile ids and model aliases — no AWS account id — but it still holds internal identifiers in plain text, so do not commit it. On a direct-API machine none of this ever runs and behaviour is unchanged.

README 발췌 (한국어)

🔀 route-scan: "이 반복 작업은 더 싼 티어로 내려도 됩니다"

세션 로그에서 상위 모델(opus/fable)이 반복 처리해 온 쉬운 작업을 찾아 haiku/sonnet 위임 룰로 승격을 제안합니다. 전 과정 로컬, 토큰 비용 0.

핵심 설계는 세 가지입니다:

  1. 난이도를 텍스트로 추측하지 않고 실제 결과로 판정합니다. 도구 에러와 변경을 일으킨 도구의 수, 출력 토큰을 근거로 삼습니다.
  2. 임계값은 사용자의 최근 14일 로그 분포에서 자동으로 보정합니다. 고정된 상수는 워크로드가 바뀌면 곧 어긋나기 때문입니다.
  3. 승격된 룰은 도구가 관리하는 별도 파일(.claude/ratchet-model.md)에서 자동으로 갱신되며, 위임한 뒤 에러율이 높아지면 ⚠ rule-health로 경고합니다. 룰이 낡았다는 사실을 스스로 알리는 셈입니다.
sprag route-scan                    # 스캔 (24h 캐시) + 티어별 후보 출력
sprag harness promote R1 --project  # 후보 R1을 모델 피팅 룰로 등록
sprag route-scan dismiss 1          # 관심 없으면 무시 (재스캔에도 안 뜸)
sprag route-scan rules              # 등록된 모델 피팅 룰 목록 (rm <N>으로 제거)
sprag route-scan savings            # 절감 원장: 어느 룰이 어떤 모델에서 어떤 모델로 옮겼는지

route-scan savings는 statusline의 🔀 Routing saved 한 줄 뒤에 있는 근거를 그대로 보여줍니다. 모델 이동별 합계와 실행별 내역이 함께 나오므로, 금액이 어디서 나왔는지 추적할 수 있습니다.

🔀 라우팅 절감 누적 $2.09  (최근 7일 $1.40 · 30일 $2.09)

모델 이동별:
  claude-fable-5 → claude-sonnet-5  —  1회, $0.72
  claude-opus-5 → claude-haiku-4-5  —  1회, $0.57

더 알아보기: 티어 기준·리서치 근거docs/TIER_CRITERIA.md · 룰 파일 구조·스캔 트리거·서브에이전트 준비docs/ROUTE_SCAN.md

게이트웨이(Bedrock·LiteLLM) 경유 환경

사내 게이트웨이를 거치면 로그의 모델명 필드에 추론 프로파일 ARN이 기록됩니다. 그 문자열에는 opus·haiku 같은 단서가 없어서 예전 버전은 이것을 전부 Sonnet으로 읽었고, 그 결과 T1(→sonnet) 위임 룰이 하나도 제안되지 않았으며 절감 집계가 0이었습니다.

매핑은 이제 세 단계로 해결합니다. 비용이 적게 드는 순서입니다.

  1. LiteLLM 게이트웨이이고 /model/info 가 열려 있는 경우. 게이트웨이가 프로파일 ID(또는 ARN)와 모델명의 대응을 이미 알고 있으므로 학습이 필요 없습니다. sprag profile-map 은 캐시된 매핑을 보여 줍니다. 게이트웨이 주소, 배포 수, 그리고 <키> → <별칭> 형식의 항목별 한 줄입니다. sprag profile-map --refreshGET /model/info 를 다시 호출해 profile-map.jsongateway 절만 갱신합니다. 이 갱신은 LiteLLM 예산 게이지를 갱신하는 자식 프로세스와 같은 주기에 얹혀 돌아갑니다. 5분마다 상태를 점검하고, 캐시된 매핑에는 24시간 TTL이 붙습니다. 그래서 최신 상태를 유지하는 데 별도 비용이 들지 않습니다.
  2. 이 경로가 없거나 /model/info 가 막힌 게이트웨이인 경우. 프로파일 ID를 역할(main·opus·sonnet·haiku)로 되돌린 뒤 ANTHROPIC_DEFAULT_*_MODEL 환경변수가 선언한 별칭으로 치환하는 기존 방식이 그대로 남습니다. 매핑은 부모 세션의 Task 호출과 서브에이전트 기록을 toolUseId로 조인해 스스로 학습합니다. 관측이 3건 미만이거나 역할 판정이 80% 미만으로 갈리면 추측하지 않고 unknown으로 두고 위임 집계에서 제외합니다.
  3. 둘 다 해결하지 못하는 경우. <userDataDir>/profile-map.json에 매핑을 직접 적습니다. 계정 ID와 리전은 *로 가려도 매칭됩니다.
{
  "modelAliases": {
    "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5",
    "prod-large": "claude-opus-5",   // 사내 별칭도 같은 방식으로 매핑됩니다
    "team-*": "claude-haiku-4-5"
  }
}

모델명에 opus·sonnet·haiku·fable 이 들어 있지 않은 사내 별칭(prod-large, team-fast 등)도 이 표로 매핑하십시오. sprag profile-map 은 게이트웨이에서 해석하지 못한 키를 함께 알려 주므로, 손으로 채울 항목을 그 목록에서 바로 확인할 수 있습니다. Bedrock(anthropic.claude-opus-4-5-v1:0)·Vertex(claude-opus-4-5@20251101)·1M 접미사(claude-sonnet-4-5[1m]) 같이 계열명이 남아 있는 형태는 그대로 인식되지만, 계열명이 사라진 별칭은 가격표가 알아볼 수 없습니다. 이 경우 라우팅 절감 계산은 틀린 금액을 내놓는 대신 그 실행을 집계에서 제외하며(비교 양쪽 모두 인식 가능한 이름이어야 합니다), 위 표에 한 줄 추가하면 다시 집계에 들어옵니다.

sprag는 인증 토큰을 저장하지 않습니다. 1번 단계의 갱신은 호출 시점에 apiKeyHelper 로 토큰을 받아 그 /model/info 요청 한 번에만 쓰고 사본을 남기지 않습니다. 토큰의 수명은 헬퍼가 소유하므로, sprag가 사본을 캐시에 두면 그 수명과 어긋나기 때문입니다. profile-map.json 에는 프로파일 ID와 모델 별칭만 들어가고 AWS 계정 ID는 들어가지 않습니다. 다만 사내 식별자가 평문으로 남으므로 저장소에 커밋하지 마십시오. 게이트웨이를 쓰지 않는 환경에서는 1번 단계 자체가 동작하지 않고 기존 동작이 그대로 유지됩니다.

More docs