feat(session-handoff): add PostToolUse commit-detector hook [v0.3.0]

Hooks live in skills/session-handoff/hooks/:
- commit-detector.ps1  Windows/PowerShell
- commit-detector.sh   POSIX (python3 for JSON parsing)
- README.md            opt-in instructions, cross-platform settings.json
                       snippets, smoke procedure

Hook reads PostToolUse stdin, detects substantive `git commit` (prefix
not in meta/docs/style/chore + fix typo, AND body >200 chars OR files >3).
On hit, emits hookSpecificOutput.additionalContext so Claude Code
surfaces a system reminder next iteration. Silent skip on --amend, failed
commits, non-git cwd, trivial prefix, below thresholds.

install.sh deliberately does NOT mutate settings.json — opt-in via the
README hook config snippet, applied once per machine. SKILL.md body
mentions the hook as an optional alternative to the agent-side
behavioral heuristic.

Bump 0.2.1 -> 0.3.0 MINOR (new opt-in capability, backward-compatible —
SKILL keeps working without the hook).

Deferred (separate follow-ups if needed):
- live-hook e2e smoke (would interfere with current session commits)
- rebase/cherry-pick batch deduplication

Closes [session-handoff-posttooluse-hook] (partial: stdin smoke,
live-hook deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 23:36:04 +03:00
parent 1c0d040347
commit cc6c321b57
6 changed files with 274 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
# Task Board # Task Board
_Updated: 2026-05-24 (session-handoff: install + bootstrap-template-extend + hermes-mapping 🟢; 3 из 7 кластера закрыто.)_ _Updated: 2026-05-24 (session-handoff: 3 baseline 🟢, posttooluse-hook + existing-projects-upgrade → 🔴.)_
<!-- <!--
Canonical layout. One block per task. Per-task deep context lives in Canonical layout. One block per task. Per-task deep context lives in

View File

@@ -0,0 +1,38 @@
# session-handoff-posttooluse-hook
## Goal
Автоматизировать substantive-commit detection в `session-handoff` через PostToolUse hook на `Bash` matcher'е (settings.json уровне), вместо поведенческой памяти агента. Hook parses `git log -1`, применяет ту же substantive-эвристику что и в SKILL.md, и на hit emits `hookSpecificOutput.additionalContext` чтобы Claude Code surface'ил system reminder в следующей итерации агента.
## Key files
- `skills/session-handoff/hooks/commit-detector.ps1` — Windows/PowerShell hook script
- `skills/session-handoff/hooks/commit-detector.sh` — Linux/macOS POSIX hook script (требует python3 для JSON parsing)
- `skills/session-handoff/hooks/README.md` — opt-in инструкции, cross-platform settings.json snippets, smoke procedure
- `skills/session-handoff/SKILL.md` — body When-to-use updated: substantive-commit пункт получил «**Optional**: harness-side hook см. hooks/README.md»
- `skills/session-handoff/SKILL.md` frontmatter — bump 0.2.1 → 0.3.0 (MINOR: new opt-in capability)
## Decisions log
- 2026-05-24: **install.sh НЕ мутирует ~/.claude/settings.json**. Auto-rewriting user hook config — неправильная shape для install скрипта. Hooks ship as files; user enables once per machine. SKILL.md и hooks/README.md документируют opt-in step (раз сделал — работает на все sessions).
- 2026-05-24: **JSON output protocol**: hook возвращает `hookSpecificOutput.additionalContext` (per Claude Code PostToolUse hook protocol). На hit — JSON; на miss — silent exit 0 без output. Confirmed via claude-code-guide subagent (https://code.claude.com/docs/en/hooks.md § JSON Output Format).
- 2026-05-24: **--amend skip** (recommended в task design questions). Amend обычно правит prev session коммит, не новый work artifact.
- 2026-05-24: **rebase/cherry-pick noise — deferred**. Hook fires per commit, batch operations spam. Trade-off acceptable for opt-in v0.3.0; defer "только original commit-event (HEAD@{1} != HEAD)" к follow-up если actually annoys.
- 2026-05-24: **first-non-trivial-commit-of-session special case — NOT in hook**. Session boundaries are agent-state, не accessible from hook side. Hook uses only body/file thresholds. Под-detection on small first commits acceptable; agent-side эвристика остаётся как backup.
- 2026-05-24: **POSIX requires python3** for safe JSON parsing of PostToolUse stdin. Alternatives (sed/awk JSON parsing) fragile. Documented as dep in README.
## Open questions
- [ ] Live-hook smoke test — отдельной сессией (enable hook → substantive commit → see additionalContext surface). Не делалось в этой сессии чтобы не interfere с current commits.
## Completed steps
- [x] Research PostToolUse hook output protocol (claude-code-guide subagent)
- [x] Inspect existing ~/.claude/settings.json (no hooks currently configured)
- [x] Write commit-detector.ps1 (Windows)
- [x] Write commit-detector.sh (POSIX)
- [x] Write hooks/README.md (opt-in instructions cross-platform + smoke procedure)
- [x] Update SKILL.md body — When-to-use mentions hook as opt-in alternative
- [x] Bump SKILL.md 0.2.1 → 0.3.0 (MINOR — new capability)
- [x] Reinstall via scripts/install.ps1
- [ ] stdin-pipe smoke (substantive HEAD + trivial HEAD)
- [ ] STATUS.md → 🟢 (partial: stdin smoke ✓, live-hook deferred)
- [ ] commit
## Notes
Live-hook enable + e2e validation = separate task / separate session. Adding hook to settings.json in this active session would fire on every git commit done here, including the closure commit itself — meta-feedback loop best avoided.

View File

@@ -1,6 +1,6 @@
--- ---
name: session-handoff name: session-handoff
version: 0.2.1 version: 0.3.0
description: "Sliding handoff between CC sessions via .tasks/NEXT_SESSION.md. Read on session start: orient agent, ask user before action. Write on session-end phrase or substantive commit. Session-end phrases: «завершаем сессию», «сворачиваемся», «закругляемся», «wrap up session», «end session», «we're done for now». Trigger-line in CLAUDE.md: `session handoff: read on start, write on end`. Skip task-zone phrases: «закрываем эту таску», «pause», «отбой», «разбегаемся»." description: "Sliding handoff between CC sessions via .tasks/NEXT_SESSION.md. Read on session start: orient agent, ask user before action. Write on session-end phrase or substantive commit. Session-end phrases: «завершаем сессию», «сворачиваемся», «закругляемся», «wrap up session», «end session», «we're done for now». Trigger-line in CLAUDE.md: `session handoff: read on start, write on end`. Skip task-zone phrases: «закрываем эту таску», «pause», «отбой», «разбегаемся»."
--- ---
@@ -26,6 +26,7 @@ Forward-looking, не timeline: handoff = связка новых вещей к
- prefix НЕ в (`meta:`|`docs:`|`style:`|`chore:`|`fix typo`) - prefix НЕ в (`meta:`|`docs:`|`style:`|`chore:`|`fix typo`)
- AND (body length > 200 символов OR files changed > 3) - AND (body length > 200 символов OR files changed > 3)
- Плюс: **первый** non-trivial commit сессии — всегда триггерит, даже если ниже порога (старт работы = context shift). - Плюс: **первый** non-trivial commit сессии — всегда триггерит, даже если ниже порога (старт работы = context shift).
- **Optional**: substantive-commit detection может быть автоматизирован harness-side через PostToolUse hook — см. `hooks/README.md` для opt-in инструкций. С enabled hook'ом первая часть becomes deterministic (parser-side, не behavioral memory).
**Skip (false-positive guards):** **Skip (false-positive guards):**
- «закрываем эту таску» — task close, не session. Это zone `using-tasks`. - «закрываем эту таску» — task close, не session. Это zone `using-tasks`.

View File

@@ -0,0 +1,91 @@
# session-handoff hooks
Opt-in PostToolUse hook that detects substantive `git commit` invocations and signals the agent ("consider running session-handoff write-mode") via a system reminder. Replaces the agent-side commit-detection heuristic in the SKILL.md `When to use` section with a deterministic harness-side trigger.
## Why opt-in (not auto-installed)
`install.sh` deliberately does **not** mutate `~/.claude/settings.json`. Auto-rewriting the user's hook config on every skill install is the wrong shape — user expects `install.sh` to copy files, nothing more. The hook is shipped as scripts; user enables it once per machine.
## Enable on Windows (PowerShell)
Add to `~/.claude/settings.json`:
```jsonc
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -ExecutionPolicy Bypass -File \"$env:USERPROFILE\\.claude\\skills\\session-handoff\\hooks\\commit-detector.ps1\""
}
]
}
]
}
}
```
If `hooks.PostToolUse` already exists — append the matcher block to the array. Don't overwrite existing entries.
## Enable on Linux / macOS (bash)
```jsonc
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/skills/session-handoff/hooks/commit-detector.sh"
}
]
}
]
}
}
```
The bash variant needs `python3` on PATH (used to parse the PostToolUse JSON stdin).
## What gets signalled
On a successful `git commit` whose subject prefix is not in {`meta`, `docs`, `style`, `chore`} or `fix typo`, **and** whose body exceeds 200 characters or which touches more than 3 files — the hook emits a `hookSpecificOutput.additionalContext` system reminder of shape:
> Substantive commit detected on `<cwd>`: `<subject>` (N files changed, body M chars). Consider invoking session-handoff write-mode to update `.tasks/NEXT_SESSION.md`.
On any of these → silent skip (exit 0, no JSON):
- malformed PostToolUse stdin
- Bash command isn't `git commit`
- command is `git commit --amend`
- commit returned non-zero exit
- cwd isn't a git work-tree
- subject prefix is in trivial set
- body ≤ 200 chars AND files ≤ 3
## Smoke test (without enabling the hook)
Pipe a synthetic PostToolUse JSON to the script. On Windows:
```powershell
$payload = @{
tool_input = @{ command = 'git commit -m "subject"' }
tool_response = @{ exit_code = 0 }
cwd = (Get-Location).Path
} | ConvertTo-Json -Compress
$payload | pwsh -NoProfile -File .\skills\session-handoff\hooks\commit-detector.ps1
```
If your `HEAD` is a non-trivial commit (e.g. recent `feat:` with > 200-char body or > 3 files), output is JSON containing `additionalContext`. Otherwise — empty stdout (silent skip).
## Caveats
- **Rebase / cherry-pick noise.** Every commit in a rebase or cherry-pick batch will re-fire the hook. Deferred to a follow-up if it actually annoys in practice; the hook is opt-in so the cost is bounded.
- **First-non-trivial commit of session.** The agent-side heuristic in SKILL.md treats the *first* non-trivial commit of a session as "always substantive" regardless of thresholds. The hook can't see session boundaries — uses only body/file thresholds. Slight under-detection on small first commits; acceptable trade-off for harness-side determinism.
- **No automatic write-mode invocation.** Hook only signals. The agent still decides whether to run session-handoff write-mode in response — keeps the user-agency invariant from SKILL.md `What NOT to do`.

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env pwsh
# session-handoff PostToolUse hook (PowerShell).
#
# Reads PostToolUse JSON from stdin, detects whether the just-completed
# Bash tool call was a substantive `git commit`, and on hit emits JSON to
# stdout with `additionalContext` so Claude Code surfaces a system reminder
# in the next agent iteration ("substantive commit — consider session-handoff
# write-mode").
#
# Substantive heuristic (mirrors session-handoff SKILL.md):
# prefix NOT in (meta:|docs:|style:|chore:|fix typo) AND
# (body > 200 chars OR files > 3)
#
# Silent skip on: malformed JSON, no command, --amend, failed commit,
# non-git cwd, trivial prefix, below thresholds. Never blocks the tool call
# (PostToolUse cannot, by design).
$ErrorActionPreference = 'Stop'
# Read stdin
try {
$raw = [Console]::In.ReadToEnd()
if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 }
$hook = $raw | ConvertFrom-Json -ErrorAction Stop
} catch {
exit 0
}
# Only Bash tool, only git commit (not --amend)
$cmd = $hook.tool_input.command
if (-not $cmd) { exit 0 }
if ($cmd -notmatch '(?<![\w-])git\s+commit(?![\w-])') { exit 0 }
if ($cmd -match '(?<![\w-])git\s+commit\b.*--amend') { exit 0 }
# Only on successful commit
if ($null -ne $hook.tool_response.exit_code -and $hook.tool_response.exit_code -ne 0) { exit 0 }
# Resolve cwd; require a git work-tree
$cwd = $hook.cwd
if (-not $cwd) { $cwd = (Get-Location).Path }
$inside = & git -C $cwd rev-parse --is-inside-work-tree 2>$null
if ($inside -ne 'true') { exit 0 }
# Parse last commit
$subject = (& git -C $cwd log -1 --format='%s').Trim()
$body = (& git -C $cwd log -1 --format='%b')
$files = ((& git -C $cwd diff-tree --no-commit-id --name-only -r HEAD) | Measure-Object).Count
# Trivial-prefix check (Conventional Commits prefix before optional scope + colon)
$prefix = $subject -replace '^([a-z]+)(\([^)]+\))?:.*$','$1'
$trivial = @('meta','docs','style','chore')
if ($trivial -contains $prefix) { exit 0 }
if ($subject -match 'fix\s+typo') { exit 0 }
# Threshold check
$bodyLen = if ($body) { $body.Length } else { 0 }
if ($bodyLen -le 200 -and $files -le 3) { exit 0 }
# Substantive — emit JSON
$msg = "Substantive commit detected on " + $cwd + ": ``" + $subject + "`` (" + $files + " files changed, body " + $bodyLen + " chars). Consider invoking session-handoff write-mode to update .tasks/NEXT_SESSION.md."
@{
hookSpecificOutput = @{
hookEventName = 'PostToolUse'
additionalContext = $msg
}
systemMessage = 'session-handoff: substantive commit detected'
suppressOutput = $false
} | ConvertTo-Json -Compress -Depth 5 | Write-Output

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# session-handoff PostToolUse hook (POSIX). See commit-detector.ps1 for prose.
set -euo pipefail
raw=$(cat)
[[ -z "$raw" ]] && exit 0
# Helper: extract a JSON path via python3
jget() {
python3 -c "
import sys, json
try:
d = json.loads(sys.argv[1])
out = d
for k in sys.argv[2].split('.'):
if isinstance(out, dict):
out = out.get(k)
else:
out = None
break
print('' if out is None else out)
" "$raw" "$1" 2>/dev/null || echo ''
}
cmd=$(jget tool_input.command)
[[ -z "$cmd" ]] && exit 0
# Only `git commit`, not `--amend`
if ! echo "$cmd" | grep -qE '(^|[^[:alnum:]_-])git[[:space:]]+commit($|[^[:alnum:]_-])'; then exit 0; fi
if echo "$cmd" | grep -qE '(^|[^[:alnum:]_-])git[[:space:]]+commit\b.*--amend'; then exit 0; fi
# Only on successful commit (if exit_code present and non-zero, skip)
exit_code=$(jget tool_response.exit_code)
if [[ -n "$exit_code" && "$exit_code" != "0" ]]; then exit 0; fi
cwd=$(jget cwd)
[[ -z "$cwd" ]] && cwd=$(pwd)
# Require git work-tree
git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
subject=$(git -C "$cwd" log -1 --format='%s')
body=$(git -C "$cwd" log -1 --format='%b')
files=$(git -C "$cwd" diff-tree --no-commit-id --name-only -r HEAD | wc -l | tr -d ' ')
# Trivial-prefix check
prefix=$(echo "$subject" | sed -E 's/^([a-z]+)(\([^)]+\))?:.*$/\1/')
case "$prefix" in
meta|docs|style|chore) exit 0 ;;
esac
echo "$subject" | grep -qE 'fix[[:space:]]+typo' && exit 0
# Threshold
body_len=${#body}
if [[ "$body_len" -le 200 && "$files" -le 3 ]]; then exit 0; fi
# Substantive — emit JSON via python3 to handle quoting safely
python3 -c "
import json, sys
msg = (
'Substantive commit detected on $cwd: \`' + '''$subject''' + '\` '
+ '($files files changed, body $body_len chars). '
+ 'Consider invoking session-handoff write-mode to update .tasks/NEXT_SESSION.md.'
)
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PostToolUse',
'additionalContext': msg,
},
'systemMessage': 'session-handoff: substantive commit detected',
'suppressOutput': False,
}))
"