#1435 — PostToolUse hook: posttool/bash/gh-rate-limit-tracker
What it does
Fires after every Bash tool call. Short-circuits if the command isn't a gh invocation (handles bare gh, absolute paths, env-var prefixes). Scans combined stdout+stderr for rate-limit indicators, injects additionalContext telling the model to stop.
// Detection — any one triggers /API rate limit exceeded/i // primary hourly limit /exceeded a secondary rate limit/i // short-burst limit /HTTP\/[0-9.]+\s+403/i + /rate limit/i // 403 + rate phrase (false-positive guarded) // Optional reset-time extraction /reset(?:\s+at)?:?\s*([0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:]+\s*(?:UTC|Z)?)/i
Output shape
[gh-rate-limit-tracker] GitHub API rate limit detected **Do not retry this gh command.** The hourly limit resets at 2026-04-21 10:00:00 UTC. Wait until that time before resuming gh calls. If this call was part of a loop (/loop 5m gh pr checks ...), stop the loop — do not let it continue firing into the rate limit.
False-positive guard
Plain HTTP 403 (permission denied, resource not accessible) does not trigger. Only 403 combined with "rate limit" phrasing in the body. Verified in tests:
test('clean 403 permission error does NOT trigger', ...) ✓ pass
#1436 — Skill eval coverage (5 files, 9 eval cases)
| Eval file | Target skill | Cases | Validates |
|---|---|---|---|
github-operations-rate-limit.eval.yaml |
github-operations | 2 | Mid-bulk-op 403 → stop iteration, wait for reset, no token rotation bypass. Pre-flight pattern guidance. |
create-pr-rate-limit.eval.yaml |
create-pr | 2 | /loop gh pr checks backs off (does NOT tighten interval). Secondary limit handled differently from primary. |
review-pr-rate-limit.eval.yaml |
review-pr | 1 | Phase 1 mid-op 403 → pause OR fall back to local git diff. Preserves Phase 1 partial state. Doesn't launch Phase 3 with incomplete context. |
doctor-reload-plugins-auto-deps.eval.yaml |
doctor | 2 | CC 2.1.116+ missing-dep → /reload-plugins first (not plugin install). Pre-2.1.116 → manual install with upgrade note. |
agent-hooks-main-thread.eval.yaml |
src/agents/README.md → .claude/rules/agent-authoring.md | 2 | New agent hooks must be context-agnostic. Skeleton generator does NOT branch on "am I a subagent?". Uses CLAUDE_AGENT_ID for logging only. |
Files touched
New files (6)
src/hooks/src/posttool/bash/gh-rate-limit-tracker.ts(~130 lines)src/hooks/src/__tests__/posttool/bash/gh-rate-limit-tracker.test.ts(19 tests)- 5 ×
tests/evals/skills/*.eval.yaml(9 eval cases total)
Registered (4)
src/hooks/hooks.json— PostToolUse/Bash entry (async:true, 5s timeout)src/hooks/src/entries/posttool.ts— import + registry entryCLAUDE.md— hook count 180→181, global 112→113hooks.json.description— 180 total → 181 total
Test count adjustments (4 files)
- split-bundles.test.ts: totalHooks 210→211
- async-registry.test.ts: asyncHooks 76→77
- dispatcher-registry-wiring.test.ts (×2): 76→77
- hooks-json-wiring.test.ts: "180 total" → "181 total"
Test plan
$ cd src/hooks && npx vitest run 7761/7761 pass $ cd src/hooks && npx tsc --noEmit clean $ npm run build ✓ 338 KB unified bundle $ npm run test:skills 106 skills pass $ npm run test:agents 37 agents pass $ npm run test:security 13/13 $ npm run test:manifests 106/106 delivered # eval YAML parse check $ python3 -c "import yaml;[yaml.safe_load(open(f)) for f in glob(...)]" 5/5 valid
Bundle impact
posttool.mjs: 84,115 bytes → 85,947 bytes (+1,832 = +2.2%)
hooks.mjs: 338.05 KB → 338.05 KB (unchanged — gh-rate-limit-tracker
is small enough to fit in existing
padding / similar symbols collapse)