Python hook contract¶
Phase 0 deliverable of the bash → Python hook migration ([#572]). Every
Python hook that ships with the dev-team plugin MUST honor this contract
byte-for-byte. During the migration (Phases 1–3), the parity harness at
plugins/dev-team/tests/hooks/parity/ mechanically enforced the contract. For
each hook, the harness ran the .sh and the .py against identical
(stdin, env, argv, initial-tree) fixtures. It asserted equal stdout, exit
code, normalized stderr, and side-effect tree. Every hook is now Python-only,
and the team retired the parity harness ([ADR 0015]). The pytest files
plugins/dev-team/tests/hooks/test_*.py now enforce the contract.
The contract mirrors the Claude Code hook payload conventions that the bash hooks already use. Nothing here is Python-specific except the authoring rules at the bottom.
stdin¶
Claude Code launches hooks with a JSON blob on stdin. The shape is the same PreToolUse / PostToolUse / SessionStart / UserPromptSubmit / Stop payload that the bash hooks already parse. All fields are optional from the hook's point of view. A well-behaved hook silently passes when a field it needs is missing, rather than crashing.
Canonical (partial) shape:
{
"session_id": "<uuid>",
"hook_event_name": "PreToolUse",
"cwd": "/abs/path/to/project",
"tool_name": "Bash",
"tool_input": {
"command": "dotnet stryker ..."
},
"tool_response": {
"exitCode": 0,
"stdout": "...",
"stderr": "..."
},
"transcript_path": "/abs/path/to/transcript.jsonl",
"prompt": "the user prompt (UserPromptSubmit only)"
}
- Read stdin with
sys.stdin.read()once. Do not usereadline(). Payloads may span multiple lines, and future events may embed newlines in string values. - Parse with
json.loads. Malformed input MUST NOT crash the hook. Treat it as advisory or silent-pass, depending on the hook's contract. - Empty stdin (
"") is a valid input for every hook. A hook that has nothing to do returns 0 with empty stdout.
stdout¶
- Stdout is the user-visible channel. Every non-empty line renders in the terminal. Treat stdout as UI, not logging.
- Do not print trailing whitespace. Do not emit ANSI color escapes.
- Prefix advisory messages with
ADVISORY:on the same line, and end them with a newline. - Prefix block messages with
[BLOCK]on the first line of a multi-line body. - Silent-pass hooks emit nothing at all.
- Encoding is UTF-8. On Windows, do not rely on the code-page default. Set
PYTHONIOENCODING=utf-8in the hook when it cannot afford mojibake.
Exit codes¶
The bash hooks use a four-tier convention that Claude Code understands:
| Code | Meaning | UX |
|---|---|---|
0 |
pass (silent or advisory) | Claude Code proceeds |
1 |
soft-fail / advisory error | Claude Code proceeds but surfaces the message |
2 |
block — Claude Code halts the tool call | required for gate hooks |
≥ 3 |
tool-specific | reserved for the hook's own consumers |
0 combined with non-empty stdout prefixed ADVISORY: is the standard
"warn but do not block" pattern (see mutation-testing-smoke-gate.sh).
Environment variables¶
Hooks MAY read the following environment variables. Claude Code or the plugin's own settings.json sets them:
CLAUDE_PROJECT_DIR— absolute path to the project root. When writing to plugin-owned side-effect trees, preferartifact_paths.project_root(). The trees are.claude/memory/,.claude/metrics/,.claude/plans/,.dev-team-reports/,.telemetry/, and.claude/hooks/. The function resolves the git root viagit rev-parse --show-toplevel. It falls back to the start directory oros.getcwd(). It does not readCLAUDE_PROJECT_DIR. The.claude/hooks/tree (issue #1904 item 10) holds plugin runtime state files./freeze,/unfreeze,/careful, and/guardwrite them.pre_tool_guard.pyanddestructive_guard.pyread them back (freeze-state.json,careful-state.json). It is NOT operator-authored hook code, despite the name collision with the top-levelhooks/directory that plugin authors do own.CLAUDE_TOOL_NAME— name of the tool being invoked (Bash,Edit, …).CLAUDE_SESSION_ID— current session UUID. The UUID is also on stdin, but Claude Code sets this env var for hooks that do not parse stdin.MUTATION_SMOKE_GATE_SKIP— hook-specific escape hatch (see the smoke-gate hook).DEV_TEAM_VERSION_CHECK_CACHE_DIR— overridesversion_check.py's daily cache directory (default/tmp). This is a test-only escape hatch (#1574). It lets pytest give each worker or run its own cache path instead of racing on the one real, shared/tmpfile. Production callers never set it.
A hook MUST NOT depend on any variable not listed here without adding the variable to this doc. That rule keeps parity fixtures reproducible across macOS + Linux + Windows Git Bash.
stderr¶
- Stderr is for advisory diagnostics that should not appear in the
terminal UI. Examples are filesystem errors, JSON parse warnings on
degenerate inputs, and timing traces during
DEV_TEAM_DEBUG=1. - Never write user-actionable messages to stderr alone. Duplicate them to
stdout with the
ADVISORY:prefix if the operator needs to see them. - Exception — exit-2 (block) messages: mirror to stderr in addition to
stdout (
pre_commit_review.py, #1367). Some Claude Code hook-error wrappers surface only stderr on a nonzero hook exit. A stdout-only block message can go completely unseen there. The operator sees only a generic "hook error, no stderr output" with no actionable reason. - Stdout stays the canonical, primary UI channel. Stderr is additive duplication for block paths specifically. It is not a general license to move messages to stderr.
- Treat dual-write as the standard for any new exit-2 hook, or any existing one you touch.
- Hooks not yet converged write to stderr only today
(
contract_version_guard.py,pre_commit_knowledge_index.py) or to stdout only today (destructive_guard.py,eval_compliance_check.py). Converging them is a separate cleanup. This note does not imply it. - The parity harness normalizes stderr before comparison. It strips ISO-8601 timestamps, PIDs, and tmpdir path prefixes. It strips nothing else. If two implementations diverge on anything past those three axes, that is a real divergence. Fix the hook. Do not widen the normalization.
Python authoring rules¶
- Stdlib-only. Zero third-party imports. Every dependency the hook
needs is in Python 3.10's stdlib:
argparse,dataclasses,hashlib,json,os,pathlib,re,shlex,shutil,signal,subprocess,sys,tempfile. Norequirements.txtfor shipped hooks. The plugin ships to users who cannotpip installon their machines. - Target Python 3.10+ (ADR 0031).
match/caseandX | Noneunions are allowed.from __future__ import annotationsis fine for type-hint delay. - CLI with argparse when a hook takes arguments. Otherwise, read stdin and dispatch on JSON fields.
- Tests: pytest. Unit tests live under
plugins/dev-team/tests/hooks/(per-hook file). The.sh↔.pyparity harness once lived underplugins/dev-team/tests/hooks/parity/. The team retired it once every hook shipped as Python-only ([ADR 0015]).test_*.pyis the coverage source of truth. - Lint: ruff. Type-check optional (
mypyis not on the CI critical path). main() -> intreturns the exit code. A trailingif __name__ == "__main__": sys.exit(main())shim runs the hook.- Signal handling. Long-running hooks (like the mutation-testing
wrapper's status loop) MUST register SIGINT/SIGTERM handlers that flush
their state before exit. See
plugins/dev-team/skills/mutation-testing/scripts/csharp_stryker_net_wrapper.py. - Cross-platform. Use
pathlib.Path, never string concatenation for paths. Never usesubprocess.run(..., shell=True)unless the command is a static literal. Otherwise, quote arguments throughshlex.quote.subprocess.runbehaves identically on all three platforms.bash -cdoes not. - Privacy boundary. Persist tokens, dollars, model IDs, or hashes —
never prompt text, code, file paths, or tool payloads. Mirror
hooks/lib/cost_meter.py's posture.
References¶
#572— the migration epic.- ADR 0015 — the migration's completion; retires the parity harness referenced above.
plans/cached-inventing-wave.md— Phase 0 architectural context.plugins/dev-team/hooks/lib/cost_meter.py,plugins/dev-team/hooks/lib/build_knowledge_index.py,plugins/dev-team/hooks/lib/telemetry_report.py— reference Python hook implementations (already in production).