Add Stop hook to pause once on 断り書き phrases#152
Merged
Conversation
AIRULES.md line 20 forbids 「正直」「実は」「本当のところ」 as preambles that soften opinions, but the rule alone is not enough — the model slips into the habit. The hook fires when one of those strings appears in the current-turn assistant text, returns decision:block once with a reason explaining why this preamble class damages trust, and then allows the second pass via stop_hook_active. The string match itself is intentionally crude; it functions as a reconsideration trigger rather than a prohibition, leaving usage judgment to the model. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Six fixes from review: - Add #!/usr/bin/env python3 shebang to match sibling hooks (approve_git_gh_commands.py et al.) — invocation goes through python3 anyway, but the shebang is the established convention. - Replace AIRULES.md line-number references with phrase references in the module docstring, REASON, and is_forbidden docstring. The REASON text gets injected back into the model on a block, so a rotted line number would actively mislead. AIRULES.md edits no longer invalidate this file. - Add stderr signals to the three I/O fall-through paths in main() (stdin parse, missing transcript_path, transcript read). Behaviour is unchanged — still exit 0 silently from Claude perspective — but a future debugging pass can grep for the block_excuse_phrases prefix in hook logs to distinguish rule-not-violated from hook-broken. - Reword module docstring once-per-turn phrasing to once-per-Stop-chain to match what the code actually guarantees (the dedup is via stop_hook_active on Stop re-entry, not a per-turn counter). - Pin intentional false positives in the is_forbidden doctest (the noun 正直者, factual disclosure 実は〜だった). The module docstring already calls these out as design choices; moving them into doctests prevents a future maintainer from tightening the regex broadness. - Add a doctest for malformed events (system events, missing or null content) in collect_current_turn_assistant_text. Locks in the silent-skip behaviour the .get() chains already provide and documents the real-transcript-carries-more-fields caveat. Doctest count: 12 to 16, all passing. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
AIRULES.md (応答の姿勢と判断 section) already forbids 「正直」「実は」「本当のところ」 as 断り書き — preamble that softens or qualifies an opinion. The rule has existed for some time, but the model continues to slip into the habit — including once in the immediately preceding session, which prompted this countermeasure. A retrospective surfaced that "follow the rule more carefully" is not a structural fix, so this PR adds a Stop hook that intercepts the slip mechanically.
Design
Reconsideration trigger, not prohibition
The hook does not try to disambiguate legitimate uses of the literal strings (e.g., 名詞 like 「正直者」, factual disclosure phrasing). A simple regex (
正直|実は|本当のところ) can't distinguish 断り書き from non-断り書き, and trying to encode that distinction in the hook would either over-block legitimate text or leak escape hatches the model can lean on. Instead, the hook treats any string match as a "stop and reconsider" trigger: it blocks the first pass with a reason explaining why this class of preamble damages trust, and on the second pass it always allows the stop viastop_hook_active.The reason text deliberately omits hook-mechanic excuses ("the second pass will go through" / "name uses are exempt"). It explains the structural harm and asks the model to judge its own usage.
stop_hook_activeand infinite-loop preventionThe hook reads
stop_hook_activefrom stdin JSON and exits 0 immediately when it is true. This is the documented "Stop hook runs forever" troubleshooting pattern at https://code.claude.com/docs/en/hooks-guide.md . Without this guard, a model that genuinely needs to use the string (a quote, a code example referencing the rule, etc.) would loop forever.Pure functions + doctest
is_forbidden(text)andcollect_current_turn_assistant_text(events)are pure and verified by doctest. The repository'spython-doctestGitHub Actions check runs these on every push, so the tests are CI-verified, not just local:The
main()entrypoint handles I/O (stdin JSON, transcript JSONL read, stdout JSON write,stop_hook_activebranch) and is verified at runtime when the hook fires in a new session.Fail-open with stderr breadcrumbs
Each I/O fall-through in
main()(stdin parse, missing transcript_path, transcript read) emits a short stderr line prefixed withblock_excuse_phrases:before exiting 0. The hook stays fail-open from Claude Code's perspective — a broken hook never wedges the assistant — but~/.claude/logs/retains a trace, so the operator can distinguish "rule not violated" from "hook silently broken".Files
The PR touches two files:
claude/hooks/block_excuse_phrases.py— the hook script itself.claude/settings.json— register the script as ahooks.Stopcommand alongside the existingnotinotification (4-line array insertion underhooks.Stop[0].hooks). The hook entry is placed beforenotiso the block decision is computed before the desktop notification fires.Verification
scripts/deploy.sh(claude/ is symlinked to ~/.claude/, so changes take effect immediately on next session). No CI path exercises Stop hooks; the integration with Claude Code is not unit-testable from CI.Out of scope