#!/usr/bin/env bash # # Catchup session-end hook. # # Wired to Claude Code's `Stop` event, this stops distillation from depending # on anyone remembering to ask for it. When a session has produced new commits, # it holds the session open and tells the agent to run the catchup skill. # # The product exists because humans do not pull knowledge out of a project. # A capture path that also waits to be pulled repeats that failure one layer # down — which is exactly what the earlier "echo a reminder" version did. # # It is deliberately quiet in the cases that matter: # # - Nothing committed since it last fired -> silent. A conversation, a # question, a failed experiment: there is no durable knowledge to capture, # and nagging about it is the treadmill the pre-mortem warned against. # - Already blocked once this turn -> silent. `stop_hook_active` # guards against an infinite stop/continue loop. # - Not a git repo, or catchup not set up here -> silent. # # Exit codes: 0 lets the session end; 2 holds it open and shows stderr to the # agent. set -uo pipefail INPUT=$(cat) json_field() { printf '%s' "$INPUT" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^,\"}]*\)\"\{0,1\}.*/\1/p" | head -1 } # Claude Code sets this when the session is already being held open by a Stop # hook. Re-blocking here would loop forever. if [[ "$(json_field stop_hook_active)" == "true" ]]; then exit 0 fi CWD=$(json_field cwd) [[ -n "$CWD" && -d "$CWD" ]] && cd "$CWD" 2>/dev/null || exit 0 git rev-parse --git-dir >/dev/null 2>&1 || exit 0 # Only nudge in projects actually connected to Catchup. Without this the hook # would fire in every repo on the machine once installed globally. # # `.catchup/project.json` is the marker the skill writes on its first run (it # keeps itself out of git with its own .gitignore); it is checked FIRST. The # older guesses remain as the fallback for repos set up before the marker # existed: a project-scope .mcp.json, CATCHUP_PROJECT_KEY, or settings.json # that names catchup. if [[ ! -f .catchup/project.json ]] \ && [[ ! -f .mcp.json && -z "${CATCHUP_PROJECT_KEY:-}" ]] \ && ! grep -qs catchup .claude/settings.json 2>/dev/null; then exit 0 fi HEAD_SHA=$(git rev-parse HEAD 2>/dev/null) || exit 0 MARKER=".git/catchup-last-capture" LAST_SHA=$(cat "$MARKER" 2>/dev/null || echo "") # First run in a repo: record where we are and stay quiet. Otherwise the hook # would demand a capture of the entire history the first time it ever fires. if [[ -z "$LAST_SHA" ]]; then printf '%s' "$HEAD_SHA" > "$MARKER" exit 0 fi [[ "$HEAD_SHA" == "$LAST_SHA" ]] && exit 0 RANGE="$LAST_SHA..$HEAD_SHA" COUNT=$(git rev-list --count "$RANGE" 2>/dev/null || echo 0) [[ "$COUNT" -eq 0 ]] && exit 0 SUBJECTS=$(git log --format=' - %h %s' "$RANGE" 2>/dev/null | head -10) # Advance the marker before blocking. If the agent ignores the instruction, the # next session is not held hostage to this one — the generation brief's coverage # gaps will surface anything genuinely missed. printf '%s' "$HEAD_SHA" > "$MARKER" cat >&2 <