Skip to content

Add Stop hook to pause once on 断り書き phrases#152

Merged
ikuwow merged 2 commits into
mainfrom
feat/excuse-phrase-stop-hook
May 1, 2026
Merged

Add Stop hook to pause once on 断り書き phrases#152
ikuwow merged 2 commits into
mainfrom
feat/excuse-phrase-stop-hook

Conversation

@ikuwow

@ikuwow ikuwow commented May 1, 2026

Copy link
Copy Markdown
Owner

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 via stop_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_active and infinite-loop prevention

The hook reads stop_hook_active from 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) and collect_current_turn_assistant_text(events) are pure and verified by doctest. The repository's python-doctest GitHub Actions check runs these on every push, so the tests are CI-verified, not just local:

$ python3 -m doctest claude/hooks/block_excuse_phrases.py -v 2>&1 | tail -3
16 tests in 4 items.
16 passed.
Test passed.

The main() entrypoint handles I/O (stdin JSON, transcript JSONL read, stdout JSON write, stop_hook_active branch) 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 with block_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:

  • New: claude/hooks/block_excuse_phrases.py — the hook script itself.
  • Modified: claude/settings.json — register the script as a hooks.Stop command alongside the existing noti notification (4-line array insertion under hooks.Stop[0].hooks). The hook entry is placed before noti so the block decision is computed before the desktop notification fires.

Verification

  • doctest: 16 tests pass locally and in CI (python-doctest job)
  • pre-commit: trailing whitespace / EOF / JSON syntax checks pass on commit
  • CI all green on this branch (Socket / bootstrap / python-doctest / shellcheck)
  • Runtime trigger: observed in a new session after the hook is installed locally via 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

  • Other AIRULES rules (no Markdown emphasis, etc.) are not enforced by hooks in this PR; they can be added incrementally if the same slip pattern emerges.
  • The pattern list mirrors the AIRULES 断り書き ban exactly; if that rule list expands, the regex needs the same edit.

ikuwow and others added 2 commits May 1, 2026 16:34
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>
@ikuwow ikuwow marked this pull request as ready for review May 1, 2026 07:43
@ikuwow ikuwow merged commit 0f49d4f into main May 1, 2026
6 checks passed
@ikuwow ikuwow deleted the feat/excuse-phrase-stop-hook branch May 1, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant