Roman Kryvolapov Engineering Blog

Хукове в Claude Code

Здравейте!

Claude Code може да се настройва дълго с текст. Да се пишат инструкции във файла на проекта, да се добавят правила, да се обяснява на агента какво не бива да прави. Това работи точно дотогава, докато моделът смята за нужно да ви слуша.

Инструкцията е молба. Хукът е код, който ще се изпълни винаги, каквото и да реши моделът този път.

Разликата е практична. Написали сте в инструкциите „форматирай кода след промените“ – понякога ще го форматира. Закачили сте форматтер за събитието след промяна на файл – форматирането става всеки път, дори когато агентът не е мислил за него.

В тази статия разглеждам хуковете цялостно: устройство, всички събития, какво може да се върне и как това променя поведението на агента. А след това показвам три хука, които работят при мен всеки ден: звук, контрол на лимитите на абонамента и автоматичен журнал на работата.

С какво хукът се отличава от правилото, скила и субагента

Хукът е команда, която Claude Code стартира сам в определени моменти от живота си. Преди извикване на инструмент, след промяна на файл, при старт на сесия, когато сте изпратили заявка, когато ходът е свършил.

На командата като вход се подава JSON с описание на събитието. Тя отговаря с код на изход и, ако е нужно, със свой JSON. Отговорът може да спре действието, да подмени аргументите на извикването, да добави текст в контекста на модела или да не променя нищо – тогава хукът е просто наблюдател.

За да не се бъркат начините за разширяване:

  • Инструкциите и правилата – текст в контекста. Моделът го чете и обикновено го изпълнява, но това е текст, който се конкурира за вниманието с останалата част от задачата.
  • Скиловете – знание при поискване. Зареждат се, когато задачата им съответства. Също текст, но по-икономичен.
  • Субагентите – отделни контексти за шумни задачи, за да не запушва проучването основния диалог.
  • MCP сървърите – нови инструменти, които моделът може да използва. Ако поиска.
  • Хуковете – детерминиран код в обход на модела. Не „може да използва“, а „ще се изпълни“.

Правилната връзка е почти винаги такава: хукът добива фактите и пази гаранциите, правилото обяснява на модела какво да прави с тези факти. На примера с контрола на бюджета по-долу се вижда, че поотделно и двете половини са безполезни.

Първият хук за пет минути

Да започнем с простото: известия, когато агентът чака вашето внимание.

Отваряме файла с настройки (глобалните стоят в домашната директория, настройките на проекта – в самия проект) и добавяме блок:

JSON · ~/.claude/settings.json
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Агентът ви чака'"
}
]
}
]
}
}

Структурата е вложена и първия път изглежда излишна. Но е логична: hooks е обект, където ключът е името на събитието; вътре – списък от групи; групата има филтър matcher и свой списък с обработчици. Няколко обработчика в една група се изпълняват паралелно.

На macOS вместо notify-send ще свърши работа osascript, на Windows – извикване на PowerShell.

Да се провери, че хукът е регистриран, може с командата /hooks направо в сесията. Тя показва всички настроени хукове с разбивка по събития и казва от кой файл с настройки е дошъл всеки. Менюто е само за четене, хуковете се редактират в JSON.

Най-честата грешка при първата настройка: файлът вече съдържа ключ hooks и новото събитие се пише вместо съществуващите, а не до тях.

Къде живеят настройките

Мястото, където сте поставили хука, определя областта на действието му:

КъдеОбластПопада ли в хранилището
Настройки на потребителя в домашната директорияВсички ваши проектиНе
Настройки на проектаЕдин проектДа, ако се комитне
Локални настройки на проектаЕдин проектНе, игнорират се от git
Корпоративни управлявани политикиЦялата организацияДа, задава ги администраторът
ПлъгинДокато плъгинът е включенДа, вътре в плъгина
Заглавие на скил или агентДокато компонентът е активенДа, вътре в компонента

Нивата се събират, а не се презаписват. Хук от потребителските настройки и хук от проектните за едно и също събитие ще се изпълнят и двата. Един и същ обработчик, описан в няколко файла с настройки, ще се изпълни веднъж: има дедупликация. А копията от плъгини и скилове се смятат за отделни, тях не ги дедуплицират.

Да се изключи всичко наведнъж може с настройката disableAllHooks. Действа в рамките на своето ниво: хуковете от корпоративните политики така не се изключват. Има и обратна настройка, allowManagedHooksOnly, с която организацията забранява изцяло потребителските, проектните и плъгин хуковете.

Промените във файловете с настройки се подхващат в движение, не е нужно да се рестартира сесията. Ако хукът не се е появил в менюто след няколко секунди, търсете грешката в JSON: висящи запетаи и коментари там са забранени.

Как хукът общува с Claude Code

Обменът е крайно прост. JSON на входа, код на изход и по желание JSON на изхода.

Какво идва на входа

При всяко събитие в стандартния вход на хука долита обект. Общите полета ги има при всички събития:

JSON
{
"session_id": "abc123",
"transcript_path": "/път/до/транскрипта.jsonl",
"cwd": "/текущата/директория",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" }
}

После всяко събитие добавя своето. Преди извикване на инструмент идват неговото име и аргументите, при изпращане на заявка – текстът на заявката, при старт на сесия – източникът на стартиране, при завършване на хода – последният отговор на асистента.

Отделно запомнете полето с пътя до транскрипта. Това е файлът, в който се записва цялата сесия ред по ред. Чрез него хукът научава това, което го няма в самото събитие: с кой модел е вървяла работата, колко токена са отишли за последната заявка, какви инструменти са извиквани през хода. И двата сложни хука по-долу се възползват от това.

Хукът получава още променливи на средата, включително корена на проекта. Това спестява ровенето с относителни пътища.

Какво може да се върне

Първият начин – код на изход:

  • 0 – няма възражения, действието продължава. При изпращане на заявка, разгръщане на команда и старт на сесия всичко, което хукът е отпечатал в стандартния изход, се добавя в контекста на модела.
  • 2 – блокиране. Това, което хукът е написал в потока за грешки, моделът го получава като обяснение и може да се поправи. Не всички събития умеят да блокират: стартът на сесия и известията например не се блокират, там съобщението просто се показва на потребителя.
  • всеки друг – грешка, но неблокираща. Действието ще продължи, а в транскрипта ще се появи ред за срив на хука.

Минимален блокиращ хук на shell:

Shell · .claude/hooks/protect-files.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.env|*package-lock.json|*.git/*)
echo "Промяната на $FILE е забранена от политиката на проекта" >&2
exit 2
;;
esac
exit 0

Вторият начин – структуриран отговор: излиза се с нула и се отпечатва JSON. Така може не просто да се забрани, а да се обясни, да се попита потребителят или да се подменят данните:

JSON
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "В този проект вместо grep се използва rg"
}
}

Стойности на решението: allow пропуска диалога за потвърждение, deny отменя извикването и предава причината на модела, ask показва на потребителя обичайната заявка за разрешение. В неинтерактивен режим има още defer, което оставя извикването висящо за външна обвивка.

Начините не бива да се смесват. При код на изход 2 вашият JSON се игнорира изцяло.

Важна асиметрия: хукът може да затегне правилата, но не може да ги отслаби. Забраната от настройките е по-силна от allow от хука. Затова пък deny от хука работи дори в режим, където потвържденията са изключени съвсем. Това прави хуковете годни за политики, които потребителят не бива да заобикаля със смяна на режима.

За добавяне на контекст има отделно поле:

JSON
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Текущ клон: release-42. Замразяване на внедряването до петък."
}
}

Тънкост, на която лесно се губи половин ден. Полето е длъжно да стои вътре във вложения обект. Написано на най-горното ниво, то мълчаливо се игнорира: грешка няма да има, просто нищо няма да се случи.

Останалите събития използват свои схеми на решенията. След извикване на инструмент и при завършване на хода това е полето decision със стойност block, при заявка за разрешение – вложен обект с поле за поведение. Общи за всички полета също има: continue със стойност „невярно“ спира агента напълно, systemMessage показва предупреждение на потребителя, suppressOutput скрива изхода на хука от транскрипта.

Размерът на отговора е ограничен до десет хиляди символа. Всичко по-дълго се записва във файл, а в контекста попадат началото и пътят. Така че хук, който изсипва гигантски лог, ще изгуби по-голямата част от него.

Всички събития

Събитията към момента на писането са над тридесет. Със списък не се запомнят, затова ето ги по групи.

Живот на сесията:

СъбитиеКога
SessionStartСесията е започнала или е възобновена
SetupЕднократна подготовка при стартиране с флагове за инициализация, за CI и скриптове
SessionEndСесията е завършила

Ход на диалога:

СъбитиеКога
UserPromptSubmitЗаявката е изпратена, но моделът още не я е видял
UserPromptExpansionВъведената команда се е разгърнала в заявка; може да се блокира
StopМоделът е свършил да отговаря
StopFailureХодът е прекъснат поради грешка на API; изходът и кодът на връщане се игнорират
MessageDisplayТекстът на отговора се извежда на екрана; може да се подмени показването, без да се пипа транскриптът

Цикъл на инструментите:

СъбитиеКога
PreToolUseПреди извикване на инструмент; може да се блокира или да се подменят аргументите
PermissionRequestНужно е решение за разрешение, може да се отговори вместо потребителя
PermissionDeniedИзвикването е отхвърлено от автоматичен класификатор
PostToolUseИнструментът е отработил успешно
PostToolUseFailureИнструментът се е провалил
PostToolBatchРазрешила се е цяла партида паралелни извиквания

Субагенти и задачи:

СъбитиеКога
SubagentStartСубагентът е стартиран
SubagentStopСубагентът е приключил
TeammateIdleУчастник от екипа от агенти преминава в престой
TaskCreatedСъздава се задача
TaskCompletedЗадачата се отбелязва като изпълнена

Файлове, директории и конфигурация:

СъбитиеКога
InstructionsLoadedЗареден е файл с инструкции или правила
ConfigChangeФайл с настройки е променен насред сесията
CwdChangedРаботната директория е сменена
DirectoryAddedВ сесията е добавена още една директория
FileChangedПроменил се е файл, за който сте поискали наблюдение
WorktreeCreate / WorktreeRemoveСъздава се или се изтрива работното дърво на git

Контекст и външният свят:

СъбитиеКога
PreCompact / PostCompactПреди и след свиването на контекста
NotificationClaude Code показва известие
Elicitation / ElicitationResultMCP сървър изисква вход от потребителя и получава отговор

Половината от списъка никога няма да ви трябва и това е нормално. На практика почти всичко полезно се гради върху осем събития: старт на сесия, изпращане на заявка, преди и след извикване на инструмент, известие, край на хода, старт и спиране на субагент.

Три неща от списъка рядко се забелязват, а напразно.

Събитието за промяна на конфигурацията позволява да се записват в журнал и дори да се забраняват промени на настройките насред сесия. Полезно там, където е важна проследимостта.

Събитието за смяна на работната директория закрива стара болка с променливите на средата. Инструменти като direnv ги превключват във вашата обвивка, но не и в обвивката на агента. Този хук заедно със събитието за старт на сесия поправя разминаването:

JSON
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
],
"CwdChanged": [
{ "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
]
}
}

А събитието за зареждане на инструкции дава точка, в която се вижда кои именно правила реално са стигнали до контекста. Незаменимо, когато правило с маски за пътища по някаква причина не се задейства.

Филтри: matcher и if

Без филтър хукът се задейства при всяка поява на своето събитие. matcher стеснява това на ниво група:

JSON
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}

Форматтерът ще се стартира след промяна на файл и няма да се стартира след четене или извикване на команда. Вертикалната черта разделя алтернативите, в по-новите версии по същия начин работи и запетаята. Празен низ или липса на полето означава „за всичко“.

Сравнението е чувствително към регистъра. Това е най-честата причина за мълчащ хук, проверявайте го първо.

При всяко събитие matcher филтрира своето поле. При събитията за инструменти – името на инструмента, при старта на сесия – начина на стартиране (обикновен старт, възобновяване, изчистване, свиване, разклоняване), при завършването – причината, при известието – типа на известието, при стартиране на субагент – типа на агента, при свиването – дали е ръчно или автоматично, при прекъсване на хода – вида на грешката на API. Част от събитията изобщо не поддържат филтри и се задействат винаги.

Инструментите на MCP се именуват по схема с двойни долни черти, където по средата стои името на сървъра. С регулярен израз лесно се хващат всички инструменти на един сървър или всички операции за запис по всички сървъри наведнъж.

Полето if е по-тънък филтър, достъпен само при събитията за инструменти. Използва синтаксиса на правилата за разрешения и гледа не само името на инструмента, но и аргументите:

JSON
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}

Тогава процесът на хука изобщо не се поражда, ако командата не отговаря на шаблона. Икономията е забележима, когато хукът виси на всяко извикване на командния ред. Разборът при това е умен: съставните команди с двоен амперсанд се проверяват по части, вложените подстановки също.

Но той е принципно „на късмет“. Ако командата не е могла да бъде разобрана, филтърът пропуска извикването към хука за всеки случай. Затова твърдите забрани се правят със системата за разрешения, а не с филтъра на хука.

Пет вида обработчици

Повечето хукове са команда. Но видовете са пет и останалите четири решават задачи, за които командата е неудобна.

Команда. Стартира изпълним файл или низ в обвивка. Основният работен вариант.

HTTP. Изпраща същото събитие с POST заявка на адрес, а решението чете от тялото на отговора. Удобно е, когато логиката се държи от общ сервис, например екипен одит на извикванията на инструменти:

JSON
{
"type": "http",
"url": "http://localhost:8080/hooks/tool-use",
"headers": { "Authorization": "Bearer $MY_TOKEN" },
"allowedEnvVars": ["MY_TOKEN"]
}

Подстановката на променливи на средата в хедърите работи само за изрично изброените имена, останалите ще останат празни. Да се блокира действие с HTTP код не може: нужен е успешен отговор със същото JSON решение като при командата.

Инструмент на MCP. Извиква инструмент на вече свързан сървър и разбира отговора му като решение.

Промпт. Решението го взема малък модел. Вие пишете въпроса, на него му се предава събитието, обратно идва „да/не“ с причина. Изход за случаи, където е нужна преценка, а не детерминиран признак:

JSON
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Провери дали всички задачи от заявката са изпълнени. Ако не – отговори {\"ok\": false, \"reason\": \"какво е останало\"}."
}
]
}
]
}
}

Отрицателният отговор при завършване на хода се връща на модела като инструкция и той продължава да работи. По подразбиране се използва бърз евтин модел, но може да се смени.

Агент. Същото, но решението го взема пълноценен субагент с инструменти. Той може да прочете файлове, да стартира тестове и чак тогава да отговори. По-скъпо и по-бавно, но проверява реалното състояние на проекта:

JSON
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Провери, че юнит тестовете минават. Стартирай ги и виж резултата. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}

Видът е експериментален, за production процеси командата е по-надеждна.

Правилото за избор е просто. Данните на събитието стигат – промпт. Трябва да се погледне кодът и да се стартира нещо – агент. Всичко останало – команда.

Как точно се стартира командата

Тук има разклонение, което обяснява половината от странните грешки на Windows.

Ако обработчикът има поле args – работи exec-форма. command се смята за път до изпълним файл, аргументите се предават точно както са написани, обвивка изобщо няма. Кавичките, доларите и обратните апострофи минават направо без интерпретация.

Няма ли поле args – работи shell-форма. Низът се подава на обвивката, която ще разгърне променливите, ще обработи конвейерите и подстановките. На macOS и Linux това е sh, на Windows – Git Bash, а ако го няма, то PowerShell. Обвивката може да се зададе изрично.

На Windows exec-формата изисква истински изпълним файл. Файловете-обвивки, които npm разгръща за своите утилити, не са изпълними и директно не се стартират. Или ги викайте през node с път до скрипта, или преминете на shell-форма.

Таймаути, фон и лимити

По подразбиране командата, HTTP и MCP обработчикът имат десет минути. Но събитието за изпращане на заявка ги свежда до тридесет секунди, показването на съобщение – до десет, а на всички хукове при завършване на сесия са отделени една секунда и половина общо. Промпт хукът чака тридесет секунди, агентният – минута.

Дългата работа се закача на фон с флага async. Такъв хук не бави сесията, но и не може да решава нищо: отговорът му вече не е нужен на никого.

Има междинен вариант, asyncRewake. Хукът работи на фон, а ако завърши с код 2, агентът ще бъде събуден и ще получи съобщението му. Единственият начин да се съобщи за провал на дълга фонова проверка.

Първи хук: звук, когато агентът чака човека

Нататък са три хука от моята практика. Ще започна с най-малкия.

Проблемът е битов. Агентът работи няколко минути, вие отивате в друг прозорец, а той спира на въпрос или на заявка за разрешение. И чака, докато си спомните за терминала.

Решението – звук при три събития: край на хода, прекъсване на хода поради грешка на API, известие.

JSON · .claude/settings.json
{
"hooks": {
"Notification": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
],
"Stop": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
],
"StopFailure": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/play-sound\"", "timeout": 15 }] }
]
}
}

Самият плейър е малка програма на Go. Цялата логика е в избора на начин за възпроизвеждане според текущата система:

Go · main.go
func main() {
defer func() {
_ = recover()
os.Exit(0) // хукът никога не събаря сесията
}()
sound := os.Args[1]
if _, err := os.Stat(sound); err != nil {
return
}
switch runtime.GOOS {
case "windows":
// вграденият плейър на .NET, нищо не е нужно да се доинсталира
play("powershell", "-NoProfile", "-Command",
fmt.Sprintf("(New-Object Media.SoundPlayer '%s').PlaySync()", sound))
case "darwin":
play("afplay", sound)
default:
// Linux: пробваме познатите плейъри един по един до първия, който проработи
for _, c := range [][]string{
{"pw-play", sound}, {"paplay", sound}, {"aplay", "-q", sound},
{"play", "-q", sound}, {"canberra-gtk-play", "-f", sound},
} {
if play(c[0], c[1:]...) {
break
}
}
}
}

Обърнете внимание на първите редове. Хукът е длъжен да е безобиден: няма звукова карта, няма плейър, няма файл – излизаме мълчаливо с нула. Хук, който се срива, превръща малкото удобство в постоянен източник на грешки в транскрипта.

Ако известията ви се струват твърде шумни, при събитието има филтри по тип: само заявка за разрешение, само престой в очакване на отговор, само завършване на фонова сесия.

Втори хук: живите лимити на абонамента

Най-полезният от трите и най-показателният. Прави това, което моделът по принцип не знае за себе си.

Абонаментът има петчасов прозорец, седмичен прозорец и отделни седмични кошници по модели. Агентът вътре в сесията тези цифри не ги вижда, затова не може да съотнася разходите. Той със същата готовност стартира рояк от десет субагента и когато до нулирането на лимита има четири часа, и когато бюджетът вече е на изчерпване. Свършва се с това, че лимитът се изчерпва по средата на задачата.

Хукът покрива четири роли наведнъж.

Извлича живите цифри. Клиентът при вход съхранява OAuth токен локално. Хукът го чете и пита API за текущото потребление, не е нужно да се създават API ключове:

Go
func fetchUsage() json.RawMessage {
b, _ := os.ReadFile(credPath) // токенът, запазен при входа
var cred struct {
ClaudeAiOauth struct {
AccessToken string `json:"accessToken"`
} `json:"claudeAiOauth"`
}
if json.Unmarshal(b, &cred) != nil || cred.ClaudeAiOauth.AccessToken == "" {
return nil
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.anthropic.com/api/oauth/usage", nil)
req.Header.Set("Authorization", "Bearer "+cred.ClaudeAiOauth.AccessToken)
// ... отговорът се кешира за минута, за да не се ходи в мрежата при всяка заявка
}

Данните са общи за целия акаунт: отчитат и другите ваши сесии, което е точно това, което трябва.

Показва ги в лентата за състояние. Лентата за състояние се настройва там, където и хуковете, и се обновява непрекъснато. Картината е постоянно пред очите:

Горният ред е запълването на контекстния прозорец: колко токена е носела последната заявка при какъв размер на прозореца. По-долу са лимитите: петчасовият прозорец, седмичният и отделната седмична кошница на модела, с който в момента върви работата.

Тук е скрит похват, който си струва да вземете за себе си независимо от Claude Code. Всеки лимит е показан с два реда. Горният – колко бюджет е изразходван, долният – колко време от прозореца вече е минало.

Сама по себе си скалата „изразходвани 35 %“ не казва нищо. Смисъл се появява, когато до нея стои втората. Бюджетната скала изпреварва времевата – до нулирането няма да издържите. Изостава – може да се работи спокойно. Две безсмислени поотделно цифри заедно дават готово решение.

Слага същите цифри в контекста на модела. При старта на сесията и при всяка заявка хукът отпечатва същата таблица в стандартния изход и излиза с нула. За тези събития това е достатъчно, за да попадне текстът в контекста, отделен JSON не е нужен. Към таблицата се добавят два реда, които ги няма в лентата за състояние: темпът на разход с прогноза кога бюджетът ще свърши и текущата зона с указание кой от лимитите в момента е ограничаващият.

Забранява роя от агенти при изчерпващ се бюджет. Заради това е цялата идея. Хукът виси на събитието преди извикване на инструмент с филтър по инструментите за стартиране на субагенти и работни процеси:

JSON
{
"PreToolUse": [
{
"matcher": "Agent|Task|Workflow",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/usage-limits\" --mode gate", "timeout": 20 }
]
}
]
}

А самата бариера в горещата зона отговаря с решение:

Go
if st.zone == "RED" || st.zone == "ORANGE" {
decision := "ask"
if st.zone == "RED" {
decision = "deny" // ройът от субагенти ще изгори остатъка от бюджета
}
out := map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason, // тук отиват цифрите и съветът какво да се прави вместо това
},
}
b, _ := json.Marshal(out)
fmt.Print(string(b))
}

В червената зона стартирането на субагент се отменя, в оранжевата се изнася за потвърждение от потребителя. Причината отива към модела като текст и той преустройва плана: работи в един поток вместо в рояк.

Още една подробност, която не може да се получи от самото събитие – запълването на контекстния прозорец. Хукът чете края на файла с транскрипта, намира последния запис с разход на токени и сумира свежия вход с четенето и записа на кеша. Получава се размерът на последната заявка. Записите на субагентите при това се прескачат: те имат свой контекст, техните числа нямат отношение към основния диалог.

И втората половина на връзката. Хукът носи фактите, но фактът сам по себе си не променя поведението. До него стои правило, което обяснява как да се четат тези цифри: да се сравнява бюджетната скала с времевата, да се съотнася дълбочината на проработка със сложността на задачата, да се смята рояк от N агента за N-кратен разход, при наближаване на прага да се стеснява обемът на работата, а не да се харчи до тавана.

Всяка половина поотделно е безполезна. Правило без хук се опира на измислица: моделът не знае своите лимити и започва да ги измисля. Хук без правило дава числа, с които никой нищо не прави. Заедно се получава работеща икономия и това според мен е главната рецепта от цялата статия.

Между другото, текстът на правилото тук е обикновен промпт и се пише по същите техники: явна таблица с прагови стойности вместо размитото „спестявай“, описан сценарий на провал, чеклист в края. Разбор на техниките има в моята статия за промпт инженеринга, а за това как са устроени правилата и скиловете изцяло – в статията за практиките при работа с Claude Code.

Трети хук: автоматичен журнал на работата

Третият хук отговаря на въпроса „какво изобщо се е случвало в тази сесия“.

Да се попита за това самият модел е лоша идея. Той ще преразкаже работата си по памет, а паметта в края на дълга сесия вече е свита: част от подробностите са загубени, част – преразказани оптимистично. Нужен е механичен запис, направен не от модела.

Хукът слуша почти всички събития на сесията: старт и край, всяка изпратена заявка, край на хода и прекъсването му, стартиране и спиране на субагенти, свиване на контекста. От събитията за инструменти – само въпросите към потребителя. Пише по един файл на сесия, с разбивка по автор и дата.

Получава се приблизително така:

management/logs/roman/2026-08-06/a1b2c3d4.log
session start: source=startup · model=claude-opus-5
════════════════════════════════════════════════════════════
prompt:
поправи безкрайното пренасочване при изтекъл токен, BUG-003
turn: model=claude-opus-5 · effort=high · 4m 12s · tools=23
(Read 9, Grep 5, Edit 3, Bash 6) · subagents=1
files:
~ apps/web/src/api/interceptors.ts +14/-6 +[41-48,52-57] -[41-46]
~ apps/web/tests/auth.spec.ts +31/-0 +[88-118]
answer:
Причината беше, че интерсепторът повтаряше заявката със стария токен...
session end: reason=prompt_input_exit · duration=51m

Три похвата от този хук заслужават отделно споменаване: от документацията не се извеждат.

Субагентите не могат да се броят по извикванията на инструменти. Стартирането на рояк е едно извикване на инструмент, което поражда десетки агенти. В транскрипта се вижда единица, в действителност са тридесет. Затова броячът се натрупва по събитията за стартиране на субагент, а редът за хода го чете и нулира:

Go
// всеки SubagentStart дописва по един байт в брояча на сесията; записът е само
// в края, затова едновременно стартиращите агенти не се затриват взаимно
func noteSubagent(session8 string) {
f, err := os.OpenFile(tallyPath(session8), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
_, _ = f.WriteString("x")
}

Съобщение, набрано по време на хода, до хука не стига. То не се изпраща, а застава на опашка, затова събитието за изпращане на заявка по него не се задейства. В журнала би се появил отговор на въпрос, който го няма там. Такива съобщения се извличат от транскрипта в края на хода и се записват с отделна бележка преди хода, който са прекъснали.

Известието за завършена фонова задача идва със същото събитие като заявката на потребителя. Да се запише като „потребителят поиска“ означава да се припишат на човека думи, които той не е казвал. Такива съобщения се разпознават по началото на текста и се пишат на отделен ред.

Промените във файловете се броят чрез diff на git спрямо последния комит, с точност до диапазони от редове. Git при това не е задължителен: няма го или директорията не е хранилище – статистиката деградира до обикновен списък с пътища, а останалото се пише както обикновено.

Всички хукове на този журнал висят на фон. Те нищо не решават, само записват, няма смисъл да се бави сесията заради тях.

Защо компилиран бинарен файл, а не скрипт

И трите хука са програми на Go, стартирани чрез тънък преходник на shell. Изборът не е очевиден, ще обясня.

Скрипт на Node или Python изглежда по-прост точно до момента, когато хукът започне да виси на всяка изпратена заявка. Стартът на интерпретатора са десетки, а понякога и стотици милисекунди за всяко събитие. Плюс зависимостта от това, че нужната среда за изпълнение е инсталирана и е в нужната версия. Компилираният бинарен файл стартира мигновено и не зависи от нищо.

Вторият довод е преносимостта. В хранилището се поставят изградени бинарни файлове за три системи, а хукът в настройките сочи към малък преходник:

Shell · .claude/hooks/usage-limits
#!/bin/sh
case "$(uname -s 2>/dev/null)" in
Darwin*) name="usage-limits-darwin-arm64" ;;
Linux*) name="usage-limits-linux-amd64" ;;
MINGW*|MSYS*|CYGWIN*) name="usage-limits-windows-amd64.exe" ;;
*) name="usage-limits-linux-amd64" ;;
esac
# ... намира бинарния файл до себе си и му предава аргументите и стандартния вход
exec "$root/scripts/claude-code/$name" "$@"
# няма бинарен файл – излизаме мълчаливо: хукът няма право да чупи сесията
exit 0

На колега, който е клонирал хранилището, не му трябва нито Go, нито Node. Само самият Claude Code. Компилаторът е нужен само на този, който променя хука.

Тук са и граблите, които ми струваха една вечер. На Windows Claude Code стартира хуковете през Git Bash, а Git Bash не смила първия ред на файл с виндоуски край на реда: получава се „лош интерпретатор“. Файлът-преходник е длъжен да се съхранява с юникс краища на редовете и това трябва да се закрепи в настройките на git:

.gitattributes
.claude/hooks/* text eol=lf

Иначе автоматичното преобразуване някой ден ще счупи всичко. Мълчаливо и на чужда машина.

Правила, които спестяват вечери

Практика, натрупана върху три хука и десетина дебъгвания.

Хукът няма право да събаря сесията. Всяка грешка – мълчалив изход с нула. Ненулев код на връщане от спомагателен хук се превръща в поток от съобщения за грешки при всеки ход.

В стандартния изход – само това, което е замислено. Ако хукът връща JSON, там не бива да попадне нито един излишен ред. Класически случай: профилът на обвивката отпечатва поздрав, той се залепя за JSON, разборът пада. Лекува се с проверка за интерактивност в профила:

Shell · ~/.bashrc
if [[ $- == *i* ]]; then
echo "Shell ready"
fi

Дебъг съобщения – в потока за грешки. Той не пречи на разбора и се вижда в дебъг лога.

Скоростта е по-важна, отколкото изглежда. Хукът при изпращане на заявка стои в критичния път: всяка излишна половин секунда чакате при всяко съобщение. Мрежовите заявки ги кеширайте.

Идемпотентност. Един и същ хук може да се задейства два пъти, паралелно, от две сесии наведнъж. Дописването в края на файл го преживява, презаписването – не.

Промените след хода си струва да се проверяват цялостно. Моделът променя файлове не само с инструментите за промяна, но и с команди на обвивката, а филтърът по инструментите за промяна такива изменения няма да види. Нужна е пълнота – добавете проверка на работното дърво при завършване на хода.

Внимавайте с хук при завършване на хода. Той може да принуди агента да продължи работа и върху това лесно се построява безкраен цикъл. Защита има, след осем поредни блокирания хукът се пренебрегва, но е по-добре самите вие да гледате полето, което съобщава, че продължението вече е предизвикано от същия този хук:

Shell
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # вече се въртим по нашето собствено изискване – излизаме
fi

Само един хук трябва да подменя аргументите на извикването. Хуковете вървят паралелно, при две промени печели тази, която е приключила последна. Редът е недетерминиран.

Сигурност

Ще кажа направо, защото хуковете са готов механизъм за изпълнение на произволен код.

Хукът се изпълнява автоматично, с вашите права, без никакво потвърждение. И получава на вход съдържанието на заявките и пътя до транскрипта. Чужда конфигурация, намерена в интернет или дошла с pull request, не е „настройка на редактора“, а код, който ще започне да се изпълнява при вас при следващото стартиране.

Оттук няколко практически следствия. Хуковете на проекта, които стоят в хранилището, си струва да се преглеждат в ревюто наравно с кода. Тайни в командите на хуковете не се пишат: файлът с настройки попада в хранилището, за токените има променливи на средата. За HTTP хуковете в организацията е предвиден списък с разрешени адреси и списък с променливи, които изобщо е разрешено да се подставят в хедърите. Администраторът може да забрани изцяло потребителските и проектните хукове, като остави само корпоративните.

От страна на самия Claude Code ограничения също има: на macOS и Linux хуковете се стартират без управляващ терминал, затова не могат да пишат направо в интерфейса или да му подхвърлят управляващи последователности.

Дебъгване

Първо три очевидни проверки, които покриват повечето случаи на „не работи“.

Командата /hooks показва дали хукът е регистриран и от кой файл с настройки е дошъл. Няма го там – въпросът е в JSON или в разположението на файла. Има го, но не се задейства – почти сигурно не е съвпаднал филтърът.

Хукът е удобно да се тества на ръка, без агент. Нужен му е само JSON в стандартния вход:

Shell
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?

Пълната картина е в дебъг лога: кои хукове са съпоставени, с какви кодове са излезли, какво са отпечатали. Включва се с флаг при стартиране или с команда насред сесия.

Типични диагнози по симптоми:

  • „command not found“ – относителен път. Вземете променливата с корена на проекта или преминете на exec-форма.
  • „jq: command not found“ – утилитата не е инсталирана. Или я инсталирайте, или разбирайте JSON с това, което вече има.
  • Хукът не се стартира изобщо на macOS или Linux – забравен е битът за изпълнение.
  • Грешка при разбор на JSON при заведомо коректен изход – страничен изход от профила на обвивката.

Какво да се закача на хуковете и какво не

Добри кандидати – всичко, където е нужна гаранция, а не намерение:

  • форматиране и линт след промени;
  • забрана за промяна на чувствителни файлове и опасни команди, с обяснение, което моделът ще прочете и ще вземе предвид;
  • известия, когато агентът чака човека;
  • добавяне на факти в контекста: клон, среда, лимити, състояние на тестовата среда;
  • журналиране и одит, особено там, където е нужна проследимост;
  • възстановяване на контекста след свиване;
  • автоматично потвърждаване на тесен, предварително избран клас заявки за разрешение.

Лоши кандидати:

  • сложна логика в хук при всяка заявка – плащате за нея със забавяне при всяко съобщение;
  • твърди забрани, които трябва да са непробиваеми: за това има система за разрешения, а филтърът на хука по аргументи работи „на късмет“;
  • всичко, което е по-удобно да се каже с думи: поведението не е критично и моделът обикновено и така прави правилното – правилото е по-евтино;
  • широко автоматично потвърждаване на разрешения: филтър от вида „за всичко“ превръща потвържденията във фикция, включително запис на файлове и стартиране на команди.

И главното, заради което е всичко това. Хуковете са единствената част на Claude Code, която не зависи от настроението на модела днес. Всичко, което трябва да се случва винаги, трябва да е хук. Останалото може да се остави на думите.

За това как е организирана работата около агента изцяло (инструкции на проекта, правила, скилове, отчитане на задачите, управление на контекста) имам отделна статия.