Free

Пусть Claude пишет хуки, которые записывают его самого

Сложнейшее в статьях-разборах — собрать материал: в git log только commit message, детали сессии теряются. Четыре хука и две slash-команды превращают каждую сессию в raw.md — предельные издержки ноль.


Самое сложное при написании статьи "Claude сделал за меня X" — не сама писанина, а сбор материала. В git log только commit message; отвергнутые варианты, провальные попытки, три переписанные версии того промпта, вывод ошибки — это и есть то, ради чего статью читают, и всё это ушло.

Хуже: к моменту, когда вы садитесь писать, вы уже не помните, почему выбрали A, а не B. "Помню, была причина" и "эта причина — X" — разница между убедительной статьёй и водой.

Протоколы встреч стоят дороже, чем статья целиком, поэтому вы их не ведёте. Единственное решение — пусть запись происходит сама — хуки.

Я заставил Claude написать 4 хука + 2 slash-команды, которые превращают каждую сессию разработки в docs/notes/<feature>/raw.md. В предыдущей статье Отдаём продакшен-деплой Claude большая часть цитируемых коммитов, фрагментов bash и ссылок на ошибки пришла из этого pipeline. Эта — тоже.


Обзор pipeline

4 хука + 2 ручные slash-команды формируют одну конвейерную ленту:

Триггер Что делает
PostToolUse(Edit\ Write\
PostToolUse(Bash) Останавливает при возврате на master + чисто
git post-commit На каждый коммит пишет checkpoint
Stop Парсит transcript в конце сессии
/record-feature NAME /stop-recording Ручная перебивка

Один файл состояния: docs/notes/.state.json. Все хуки читают и пишут в него. Больше координации нет.

Хук №1: когда начать

"PostToolUse": [
  {
    "matcher": "Edit|Write|MultiEdit",
    "hooks": [{
      "type": "command",
      "command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
    }]
  }
]

Ядро maybe-start:

def cmd_maybe_start():
    if load_state(): return          # уже записываем
    if not tree_is_dirty(): return   # ничего не поменялось, пропуск
    feature = branch_to_feature(current_branch())
    if not feature:
        feature = "session-" + datetime.now().strftime("%Y%m%d-%H%M")
    save_state({"feature": feature, "auto_started": True, ...})

branch_to_feature("feature/pro") возвращает "pro". Разработчики и так делят работу по веткам, так что переиспользуем эту ментальную границу — коренная причина, по которой pipeline выживает: он не просит пользователя запоминать ничего нового. На master одноразовые скрипты откатываются к timestamp.

Почему matcher — Edit/Write/MultiEdit: писать код — это настоящий сигнал "я правда что-то делаю". Читать файлы, гонять тесты, задавать вопросы — не считается.

Хук №2: когда остановить

{
  "matcher": "Bash",
  "hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop" }]
}
def cmd_maybe_stop():
    state = load_state()
    if not state or not state.get("auto_started"): return
    if current_branch() not in ("master", "main"): return
    if tree_is_dirty(): return
    clear_state()

Три шлюза: ручные записи не останавливаются, feature-ветки не останавливаются, грязное дерево не останавливается.

Почему повешен на Bash, а не на Edit: после мержа в master обычно уже не редактируешь, но точно запустишь git status / git log / bin/rails test — любая команда даёт хуку шанс заметить "пора закругляться".

Флаг auto_started — несущий элемент. Если ты запустил /record-feature pro-launch и прошёл несколько веток через несколько мержей, правила auto-stop прикончили бы запись посреди фичи. Ручные записи никогда не останавливаются автоматически — только /stop-recording.

Хук №3: commit как checkpoint

Не Claude Code хук — это .git/hooks/post-commit:

#!/bin/bash
ROOT=$(git rev-parse --show-toplevel) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true

recording-state commit дописывает полное сообщение коммита в raw.md:

### Commit 2026-04-16 21:55: `71f38a1`
> Add pricing page and expand account UI (P6 phases 1-2)
> 
> Pricing (/pricing):
> - Displays all 6 plans in monthly/yearly grid with Stimulus toggle
> - Anonymous users see Subscribe → sign-in flow
> ...

Почему commit message важны: это написанное вручную резюме, которое ты (или Claude) произвёл ровно в момент, когда фича только что закончена, а контекст полон. Точнее, чем вспоминать потом, короче transcript'а, абстрактнее diff'а.

Когда Claude пишет commit message, он по сути пишет материал-резюме для твоей будущей статьи — тебе нужно только, чтобы он сделал это хорошо с первого раза, без переписывания потом.

Хук №4: извлечение событий из transcript

"Stop": [{
  "hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }]
}]

Когда сессия заканчивается, Claude Code передаёт transcript_path в хук через stdin как JSON. extract-session-notes открывает этот jsonl, идёт по нему строка за строкой и раскладывает по вёдрам:

keep_patterns = (
    "test", "spec", "rspec", "minitest",
    "kamal", "git commit", "git push",
    "rails db", "rails routes", "rails runner",
    "migrate", "curl -X", "curl -s -X",
)
if any(kw in cmd for kw in keep_patterns):
    bash_cmds.append({"cmd": cmd[:400], "desc": desc})

Захватываются только bash-команды из whitelist. 90% bash в сессии кода — это ls / cat / grep / head — в статьях не используются, отфильтровываем всё. Оставшееся (запуск тестов, kamal, rails runner, curl по API) — команды с историей.

Пользовательские промпты в обёртках <command-*> или <system-*> выбрасываются — выживает только настоящий ввод. Пути Edit/Write дедуплицируются в set. Вывод ошибок обрезается до 400 символов. Вызовы Task sub-agent сохраняются (промпт обрезается до 2000 символов — достаточно, чтобы увидеть, что sub-agent сделал).

Одна запись, много Stop: используй курсор

Хук Stop срабатывает не один раз на запись — он срабатывает каждый раз, когда сессия заканчивается. На одной и той же feature-ветке ты можешь открыть и закрыть Claude Code пять-шесть раз. Если каждый Stop будет пере-парсить весь transcript и писать всё, raw.md утонет в дубликатах.

Решение: поставить курсор last_extracted_at в файле состояния:

filter_after = state.get("last_extracted_at") or state.get("started_at")
events = extract_events(transcript_path, filter_after)
# ...после записи...
state["last_extracted_at"] = datetime.now().astimezone().isoformat()
save_state(project_dir, state)

Каждый проход берёт только события после курсора. Просто, но забудь — и сыплются сотни дублированных строк.

Ручная перебивка: две slash-команды

/record-feature NAME:

{
  "feature": "NAME",
  "started_at": "ISO timestamp",
  "branch": "current branch",
  "auto_started": false
}

Строка auto_started: false отключает auto-stop. Сценарии: пишешь одноразовый скрипт на master, но хочешь оставить след; фича, пересекающая несколько веток; явно заявить "эта под статью".

/stop-recording: прогоняет финальное извлечение по самому свежему jsonl, затем чистит файл состояния.

Как выглядит raw.md

Реальный фрагмент (из docs/notes/pro/raw.md):

## Session 2026-04-16 21:52 (`7a81bf9d`)

### User prompts
- 根据环境变量直接写好对应只,不用传。

### Files edited/written
- `config/deploy.yml`
- `config/initializers/x402.rb`

### Commit 2026-04-17 00:13: `f87ea8e`
> Add production credentials (Stripe live + x402 mainnet)
> ...

### Commit 2026-04-17 00:57: `eba9ac9`
> Fix production x402 wallet_address (stray fullwidth '?' at end)

Чтобы написать статью: просто grep. "Какой коммит был фиксом wallet address?" → eba9ac9. "Как я промптил sub-agent рефакторинга?" → под ### Sub-agent invocations.

docs/notes/ в .gitignore — это черновой материал для писанины, не исходный код.

Три неочевидных дизайн-решения

1. Ветка как граница фичи, а не отдельная система метаданных. Разработчики уже делят работу по веткам; добавление ещё одного слоя "имя фичи" неизбежно расползётся. Переиспользовать существующую границу — нулевая когнитивная нагрузка.

2. Whitelist для bash, а не blacklist. 95% bash в transcript'ах — шум. Список "что стоит сохранять" стабилен годами; список "что фильтровать" — нет.

3. Курсор живёт в файле состояния, а не в transcript'е. Transcript принадлежит Claude Code; state принадлежит pipeline. Развязаны — Claude Code может менять формат transcript'а, не ломая меня, а я могу переписывать логику извлечения, не трогая transcript'ы.

Замыкая петлю

Написание статьи на 1500 слов потребляет материал примерно из 30 коммитов, 4 сессий, десятка ключевых bash-команд. Собрать вручную — около часа, хватает, чтобы в следующий раз ты срезал углы.

Пусть хуки делают это: предельные издержки — ноль. Установи это один раз и просто работай как обычно — тяни ветку, пиши код, деплой — и материал для статьи копится сам.

Каждая ссылка на коммит, фрагмент bash, строка ошибки и путь к файлу в этой статье пришли из docs/notes/pro/raw.md — черновой материал, который хук записал сам с момента, когда я два дня назад потянул feature/pro. Когда пришло время писать, я открыл файл — и всё, что нужно, уже было там.

Лучший способ заставить Claude писать статьи про Claude Code — сначала заставить Claude писать хуки, которые записывают его самого.