← Все артефакты

Хуки-напоминания в Claude Code

Правила, которые держатся на памяти человека или агента, срабатывают нерегулярно. Хук выполняется самой программой при каждом событии, поэтому напоминание приходит всегда. Ниже два готовых примера и общий способ подключения.

Как это работает

Хук UserPromptSubmit запускается перед каждым сообщением пользователя. Скрипт получает JSON со stdin (путь к журналу сессии transcript_path, session_id), а в ответ может вывести JSON с additionalContext. Этот текст попадает агенту как дополнительный контекст, и агент учитывает его в ответе. Если скрипту нечего сказать, он молча завершается.

Подключение

В ~/.claude/settings.json добавить:

{
  "hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "python3 /путь/к/context-reminder.py" },
{ "type": "command", "command": "python3 /путь/к/result-reminder.py" }
]
}
]
  }
}

Скрипты положить в ~/.claude/hooks/. Изменения подхватываются в новых сессиях.

Правила хорошего хука:

Пример 1. Напоминание про длинную сессию

Смотрит на размер журнала и время сессии. От 2 МБ или 6 часов напоминает редко, от 5 МБ или 12 часов чаще. Подробнее про причины: инструкция «Как уложиться в лимиты Claude».

#!/usr/bin/env python3
"""Напоминает про /compact и /clear, когда сессия стала длинной (экономия лимитов).
Читает JSON хука UserPromptSubmit со stdin. Размер журнала сессии это грубая мера контекста
(после /compact журнал не уменьшается, поэтому предупреждение может быть чуть лишним, это нормально)."""
import json, os, sys, time, datetime
try:
d = json.load(sys.stdin)
tp = d.get('transcript_path') or ''
sid = d.get('session_id') or 'x'
size = os.path.getsize(tp) if tp and os.path.exists(tp) else 0
hours = 0.0
if size:
with open(tp, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
try:
ts = json.loads(line).get('timestamp')
except Exception:
continue
if ts:
t0 = datetime.datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
hours = (time.time() - t0) / 3600
break
mb = size / 1048576
level = 2 if (mb >= 5 or hours >= 12) else 1 if (mb >= 2 or hours >= 6) else 0
if not level:
sys.exit(0)
state = f'/tmp/ctx-reminder-{sid}'
n = 0
try:
n = int(open(state).read())
except Exception:
pass
open(state, 'w').write(str(n + 1))
if n % (8 if level == 1 else 4) != 0:
sys.exit(0)
msg = (f'Сессия длинная: журнал {mb:.1f} МБ, идёт {hours:.0f} ч. Длинные сессии съедают лимиты. '
'Закончи текущую задачу и сделай /clear (или /compact, если задача не закончена); '
'новую тему лучше вести в новой сессии в папке этого проекта.')
print(json.dumps({
'systemMessage': msg,
'hookSpecificOutput': {
'hookEventName': 'UserPromptSubmit',
'additionalContext': 'Напомни пользователю одной фразой про /clear или /compact: ' + msg
}}, ensure_ascii=False))
except Exception:
sys.exit(0)

Пример 2. Напоминание упаковать результат

Замечает новые файлы, которые агент создал в сессии (кроме дневников, библиотеки и временных папок). Решение принимает не скрипт, а агент: он оценивает, получается ли из сделанного инструкция, скилл, скрипт, плагин, карусель, лидмагнит или другая конкретная вещь. Если да, он одной строкой предлагает сохранить результат в библиотеку. Если задача ещё посреди пути, он молчит. Не чаще раза в 3 минуты и только по новым файлам.

#!/usr/bin/env python3
"""Напоминает упаковать результат работы: когда в сессии появился новый файл-результат,
подмешивает агенту подсказку оценить, не получается ли из него инструкция, скилл, скрипт,
плагин, карусель, лидмагнит и т.п. Решает сам агент, скрипт только замечает новые файлы.
Читает JSON хука UserPromptSubmit со stdin."""
import json, os, sys, time

EXT = {'.md', '.html', '.py', '.sh', '.js', '.ts', '.json', '.css', '.swift'}
SKIP = ('/memory/', '/library/', '/scratchpad', '/tmp/', '/.claude/projects/', '/node_modules/', '/.git/')
SKIP_NAMES = {'_progress.md', 'MEMORY.md', 'CLAUDE.md', 'README.md'}
MIN_GAP = 180  # секунд между напоминаниями

try:
d = json.load(sys.stdin)
tp = d.get('transcript_path') or ''
sid = d.get('session_id') or 'x'
if not tp or not os.path.exists(tp):
sys.exit(0)
results = []
with open(tp, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
if '"Write"' not in line:
continue
try:
c = (json.loads(line).get('message') or {}).get('content')
except Exception:
continue
if not isinstance(c, list):
continue
for x in c:
if x.get('type') == 'tool_use' and x.get('name') == 'Write':
p = (x.get('input') or {}).get('file_path') or ''
if os.path.splitext(p)[1] in EXT and os.path.basename(p) not in SKIP_NAMES \
and not any(s in p for s in SKIP) and p not in results:
results.append(p)
state = f'/tmp/result-reminder-{sid}'
seen, last = 0, 0.0
try:
seen, last = (float(v) for v in open(state).read().split())
seen = int(seen)
except Exception:
pass
if len(results) <= seen or time.time() - last < MIN_GAP:
sys.exit(0)
open(state, 'w').write(f'{len(results)} {time.time()}')
new = [os.path.basename(p) for p in results[seen:]][:5]
msg = ('В сессии появились новые файлы: ' + ', '.join(new) + '. '
'Оцени, не готов ли результат к упаковке в библиотеку: инструкция, скилл, скрипт, плагин, '
'схема, карусель, лидмагнит или другое конкретное. Если да и работа на законченном месте, '
'одной строкой скажи, что из этого может получиться, и предложи сохранить в library/. '
'Если нет или задача посреди пути, ничего не говори.')
print(json.dumps({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',
'additionalContext': msg}}, ensure_ascii=False))
except Exception:
sys.exit(0)