claude-dev-env 1.74.0 → 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/hooks/blocking/CLAUDE.md +1 -0
  2. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
  3. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  4. package/hooks/blocking/hedging_language_blocker.py +1 -13
  5. package/hooks/blocking/intent_only_ending_blocker.py +1 -15
  6. package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
  7. package/hooks/blocking/question_to_user_enforcer.py +1 -11
  8. package/hooks/blocking/session_handoff_blocker.py +1 -15
  9. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
  10. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  11. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  12. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  13. package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
  14. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  15. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  16. package/hooks/hooks_constants/CLAUDE.md +4 -1
  17. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  18. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  19. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
  20. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
  21. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  22. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  23. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  24. package/hooks/hooks_constants/text_stripping.py +36 -0
  25. package/hooks/validation/CLAUDE.md +1 -0
  26. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  27. package/hooks/validation/test_mypy_validator.py +1 -1
  28. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  29. package/hooks/workflow/auto_formatter.py +8 -5
  30. package/hooks/workflow/test_auto_formatter.py +33 -0
  31. package/package.json +1 -1
  32. package/rules/windows-filesystem-safe.md +2 -0
  33. package/skills/autoconverge/SKILL.md +6 -3
  34. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  35. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  36. package/skills/autoconverge/workflow/converge.contract.test.mjs +308 -132
  37. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  38. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  39. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  40. package/skills/autoconverge/workflow/converge.mjs +598 -606
  41. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  42. package/skills/autoconverge/workflow/render_report.py +2 -6
  43. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  44. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -26,6 +26,7 @@ if _HOOKS_DIR not in sys.path:
26
26
 
27
27
  from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
28
28
  ALL_HOSTED_HOOK_ENTRIES,
29
+ BLOCKING_CRASH_DENY_REASON,
29
30
  BLOCKING_CRASH_EXIT_CODE,
30
31
  DENY_DECISION,
31
32
  EDIT_TOOL_NAME,
@@ -485,6 +486,52 @@ def test_aggregate_exit_code_two_signals_deny() -> None:
485
486
  )
486
487
 
487
488
 
489
+ def test_aggregate_blocking_hook_crash_surfaces_a_deny() -> None:
490
+ """A crash in a blocking hook surfaces a deny with the crash reason.
491
+
492
+ When a blocking hook raises a non-SystemExit exception before emitting any
493
+ output, the aggregator must still deny so a bad write does not silently
494
+ pass. The deny reason must be the BLOCKING_CRASH_DENY_REASON constant.
495
+ """
496
+ all_results = [
497
+ HostedHookResult(
498
+ exit_code=0,
499
+ captured_stdout="",
500
+ did_crash=True,
501
+ is_blocking=True,
502
+ )
503
+ ]
504
+ decision = aggregate_hosted_hook_results(all_results)
505
+ assert decision.should_deny, "a blocking hook crash must surface a deny"
506
+ assert decision.all_deny_reasons, (
507
+ "the deny reasons list must be non-empty after a blocking hook crash"
508
+ )
509
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons, (
510
+ "the deny reason from a blocking hook crash must be BLOCKING_CRASH_DENY_REASON.\n"
511
+ f"Got: {decision.all_deny_reasons!r}"
512
+ )
513
+
514
+
515
+ def test_aggregate_non_blocking_hook_crash_does_not_deny() -> None:
516
+ """A crash in a non-blocking hook does not change an allow to a deny.
517
+
518
+ A hosted hook carrying is_blocking=False must not surface a deny when it
519
+ crashes — the aggregated decision stays allow.
520
+ """
521
+ all_results = [
522
+ HostedHookResult(
523
+ exit_code=0,
524
+ captured_stdout="",
525
+ did_crash=True,
526
+ is_blocking=False,
527
+ )
528
+ ]
529
+ decision = aggregate_hosted_hook_results(all_results)
530
+ assert not decision.should_deny, (
531
+ "a non-blocking hook crash must not change an allow to a deny"
532
+ )
533
+
534
+
488
535
  def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
489
536
  """A HostedHookResult with exit_code 0 and empty stdout signals allow.
490
537
 
@@ -585,16 +632,16 @@ def test_dispatcher_write_applies_both_groups() -> None:
585
632
  assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
586
633
  "plain_language_blocker (Group B) must be in Write applicable set"
587
634
  )
588
- assert len(all_write_entries) == 17, (
589
- f"Write tool must apply to all 17 hosted hooks, got {len(all_write_entries)}"
635
+ assert len(all_write_entries) == 18, (
636
+ f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
590
637
  )
591
638
 
592
639
 
593
640
  def test_dispatcher_edit_applies_both_groups() -> None:
594
641
  """Edit tool triggers both Group A and Group B hooks through the dispatcher."""
595
642
  all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
596
- assert len(all_edit_entries) == 17, (
597
- f"Edit tool must apply to all 17 hosted hooks, got {len(all_edit_entries)}"
643
+ assert len(all_edit_entries) == 18, (
644
+ f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
598
645
  )
599
646
 
600
647
 
@@ -613,7 +660,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
613
660
  it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
614
661
  A Python Write payload that run_all_validators would have flagged (mypy errors, for
615
662
  instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
616
- dispatcher covers only its 17 hosted blocking hooks — none of which includes the
663
+ dispatcher covers only its 18 hosted blocking hooks — none of which includes the
617
664
  validators runner.
618
665
  """
619
666
  python_content_with_type_error = (
@@ -14,7 +14,9 @@ if _HOOKS_DIR not in sys.path:
14
14
  sys.path.insert(0, _HOOKS_DIR)
15
15
  if _HOOKS_ROOT not in sys.path:
16
16
  sys.path.insert(0, _HOOKS_ROOT)
17
+ import question_to_user_enforcer
17
18
  from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE
19
+ from hooks_constants.text_stripping import strip_code_and_quotes
18
20
 
19
21
  CLEAN_DECLARATIVE_MESSAGE = "I applied the rename across both files. The tests pass."
20
22
  TRAILING_QUESTION_MESSAGE = (
@@ -68,6 +70,10 @@ def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess
68
70
  return run_hook_with_payload({"last_assistant_message": assistant_message})
69
71
 
70
72
 
73
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
74
+ assert question_to_user_enforcer.strip_code_and_quotes is strip_code_and_quotes
75
+
76
+
71
77
  def test_clean_declarative_message_passes_through():
72
78
  completed_process = run_hook_with_message(CLEAN_DECLARATIVE_MESSAGE)
73
79
  assert completed_process.returncode == 0
@@ -16,6 +16,12 @@ if _HOOKS_ROOT not in sys.path:
16
16
  sys.path.insert(0, _HOOKS_ROOT)
17
17
  import session_handoff_blocker
18
18
  from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE
19
+ from hooks_constants.text_stripping import strip_code_and_quotes
20
+
21
+
22
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
23
+ assert session_handoff_blocker.strip_code_and_quotes is strip_code_and_quotes
24
+
19
25
 
20
26
  NEW_SESSION_PROPOSAL_MESSAGE = (
21
27
  "I recommend we continue this in a fresh session to keep things manageable."
@@ -24,9 +24,11 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
24
24
  | `doc_gist_auto_publish_constants.py` | Sentinel marker and URL patterns for the doc-gist auto-publish hook |
25
25
  | `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
26
26
  | `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
27
+ | `duplicate_rmtree_helper_blocker_constants.py` | Sanctioned Windows-safe rmtree helper names, the definition pattern, and the exempt-path set for the duplicate-rmtree-helper blocker |
27
28
  | `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
28
29
  | `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
29
30
  | `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
31
+ | `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
30
32
  | `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
31
33
  | `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
32
34
  | `html_companion_constants.py` | Blocked URL schemes and other config for the `.md`-to-`.html` companion hook |
@@ -55,6 +57,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
55
57
  | `stuttering_import_binding_constants.py` | Import-binding patterns for the stuttering check |
56
58
  | `subprocess_budget_completeness_constants.py` | Required argument names for the subprocess-budget completeness check |
57
59
  | `sys_path_insert_constants.py` | Patterns for detecting unguarded `sys.path.insert` calls |
60
+ | `text_stripping.py` | `strip_code_and_quotes()` — shared helper that removes fenced code blocks, inline code, and blockquotes from prose, imported by the Stop-hook prose blockers |
58
61
  | `unused_module_import_constants.py` | Patterns for detecting unused module-level imports |
59
62
  | `windows_rmtree_blocker_constants.py` | The unsafe `shutil.rmtree` pattern and the safe replacement pattern |
60
63
  | `workflow_substitution_slot_blocker_constants.py` | Per-iteration token patterns for the workflow-slot blocker |
@@ -64,4 +67,4 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
64
67
  - Every file in this package is a pure constants module — no side effects, no I/O.
65
68
  - Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
66
69
  - Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
67
- - `dynamic_stderr_handler.py` and `pre_tool_use_stdin.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
70
+ - `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
@@ -2,7 +2,8 @@
2
2
 
3
3
  The gate denies an ``Agent`` spawn whose ``subagent_type`` is ``code-verifier``
4
4
  when the branch carries a merge conflict against its base ref or a CODE_RULES
5
- violation on a line added in the uncommitted working tree. It runs two
5
+ violation on a line added in the working tree since the merge base (committed on
6
+ the branch or uncommitted). It runs two
6
7
  pre-flight checks before the expensive verification spawn and addresses its
7
8
  deny reason to the spawning agent so that agent fixes the named issues and
8
9
  re-spawns. Every literal the hook body reads lives here; the hook imports
@@ -0,0 +1,27 @@
1
+ """Configuration constants for the duplicate_rmtree_helper_blocker PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ PYTHON_FILE_EXTENSION: str = ".py"
6
+
7
+ HELPER_DEFINITION_PATTERN: re.Pattern[str] = re.compile(
8
+ r"^[ \t]*def[ \t]+(?:_strip_read_only_and_retry|_force_remove_tree|force_rmtree)[ \t]*\(",
9
+ re.MULTILINE,
10
+ )
11
+
12
+ TRIPLE_QUOTED_STRING_PATTERN: re.Pattern[str] = re.compile(
13
+ r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'',
14
+ )
15
+
16
+ ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
17
+ "windows_rmtree_blocker.py",
18
+ "duplicate_rmtree_helper_blocker.py",
19
+ "windows_safe_rmtree.py",
20
+ "windows_filesystem.py",
21
+ "session_env_cleanup.py",
22
+ "_md_to_html_blocker_test_support.py",
23
+ "teardown_worktrees.py",
24
+ )
25
+
26
+ ALL_EXEMPT_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
27
+ ALL_EXEMPT_TEST_FILE_SUFFIXES: tuple[str, ...] = ("_test.py",)
@@ -15,6 +15,7 @@ __all__ = [
15
15
  "REASON_KEY",
16
16
  "HOOK_EVENT_NAME",
17
17
  "EMPTY_REASON_BLOCK_FALLBACK",
18
+ "BLOCKING_CRASH_DENY_REASON",
18
19
  "PLUGIN_ROOT_PLACEHOLDER",
19
20
  "PostHostedHookEntry",
20
21
  "ALL_POST_HOSTED_HOOK_ENTRIES",
@@ -25,6 +26,7 @@ DECISION_KEY = "decision"
25
26
  REASON_KEY = "reason"
26
27
  HOOK_EVENT_NAME = "PostToolUse"
27
28
  EMPTY_REASON_BLOCK_FALLBACK = "[dispatcher] hook blocked with no reason — write blocked"
29
+ BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
28
30
 
29
31
  PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
30
32
 
@@ -15,6 +15,7 @@ __all__ = [
15
15
  "HOOK_EVENT_NAME",
16
16
  "BLOCKING_CRASH_EXIT_CODE",
17
17
  "EXIT_CODE_TWO_DENY_REASON",
18
+ "BLOCKING_CRASH_DENY_REASON",
18
19
  "WRITE_TOOL_NAME",
19
20
  "EDIT_TOOL_NAME",
20
21
  "MULTI_EDIT_TOOL_NAME",
@@ -31,6 +32,7 @@ ALLOW_DECISION = "allow"
31
32
  HOOK_EVENT_NAME = "PreToolUse"
32
33
  BLOCKING_CRASH_EXIT_CODE = 2
33
34
  EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
35
+ BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
34
36
 
35
37
  WRITE_TOOL_NAME = "Write"
36
38
  EDIT_TOOL_NAME = "Edit"
@@ -59,8 +61,8 @@ class HostedHookEntry:
59
61
  native_module_name: The importable module name whose evaluate function
60
62
  the dispatcher calls in-process for this hook, or None when the hook
61
63
  runs via runpy under __main__. The named module exposes a function
62
- named NATIVE_EVALUATE_FUNCTION_NAME taking the payload dict and
63
- returning a deny-reason string or None.
64
+ named `evaluate` taking the payload dict and returning a deny-reason
65
+ string or None.
64
66
  """
65
67
 
66
68
  script_relative_path: str
@@ -94,6 +96,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
94
96
  script_relative_path="blocking/windows_rmtree_blocker.py",
95
97
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
96
98
  ),
99
+ HostedHookEntry(
100
+ script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
101
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
102
+ ),
97
103
  HostedHookEntry(
98
104
  script_relative_path="blocking/state_description_blocker.py",
99
105
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
@@ -0,0 +1,43 @@
1
+ """Tests for the PostToolUse dispatcher constants module."""
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ _VALIDATION_DIR = _HOOKS_ROOT / "validation"
11
+ if str(_VALIDATION_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_VALIDATION_DIR))
13
+
14
+ from post_tool_use_dispatcher import (
15
+ PostHostedHookResult,
16
+ aggregate_post_hosted_hook_results,
17
+ )
18
+
19
+ from hooks_constants.post_tool_use_dispatcher_constants import (
20
+ BLOCKING_CRASH_DENY_REASON,
21
+ )
22
+
23
+
24
+ def test_blocking_hook_crash_block_reason_surfaces_the_constant() -> None:
25
+ crash_result = PostHostedHookResult(
26
+ captured_stdout="",
27
+ did_crash=True,
28
+ is_blocking=True,
29
+ )
30
+ decision = aggregate_post_hosted_hook_results([crash_result])
31
+ assert decision.should_block
32
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_block_reasons
33
+
34
+
35
+ def test_non_blocking_hook_crash_does_not_surface_the_constant() -> None:
36
+ crash_result = PostHostedHookResult(
37
+ captured_stdout="",
38
+ did_crash=True,
39
+ is_blocking=False,
40
+ )
41
+ decision = aggregate_post_hosted_hook_results([crash_result])
42
+ assert not decision.should_block
43
+ assert BLOCKING_CRASH_DENY_REASON not in decision.all_block_reasons
@@ -0,0 +1,99 @@
1
+ """Tests for the PreToolUse dispatcher hosted-hook roster."""
2
+
3
+ import importlib
4
+ import pathlib
5
+ import sys
6
+
7
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
8
+ if str(_HOOKS_ROOT) not in sys.path:
9
+ sys.path.insert(0, str(_HOOKS_ROOT))
10
+
11
+ _BLOCKING_DIR = _HOOKS_ROOT / "blocking"
12
+ if str(_BLOCKING_DIR) not in sys.path:
13
+ sys.path.insert(0, str(_BLOCKING_DIR))
14
+
15
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
16
+ ALL_HOSTED_HOOK_ENTRIES,
17
+ ALL_WRITE_AND_EDIT_TOOL_NAMES,
18
+ BLOCKING_CRASH_DENY_REASON,
19
+ EDIT_TOOL_NAME,
20
+ WRITE_TOOL_NAME,
21
+ )
22
+ from pre_tool_use_dispatcher import (
23
+ HostedHookResult,
24
+ aggregate_hosted_hook_results,
25
+ )
26
+
27
+
28
+ def _entry_for(script_relative_path: str):
29
+ matching_entries = [
30
+ each_entry
31
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
32
+ if each_entry.script_relative_path == script_relative_path
33
+ ]
34
+ return matching_entries[0] if matching_entries else None
35
+
36
+
37
+ def test_roster_includes_duplicate_rmtree_helper_blocker_script_path() -> None:
38
+ all_registered_script_paths = [
39
+ each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
40
+ ]
41
+ assert "blocking/duplicate_rmtree_helper_blocker.py" in all_registered_script_paths, (
42
+ "duplicate_rmtree_helper_blocker must be hosted by the dispatcher so a local "
43
+ "re-definition of the Windows-safe rmtree helper trio is blocked at Write time"
44
+ )
45
+
46
+
47
+ def test_duplicate_rmtree_helper_blocker_applies_to_write_and_edit() -> None:
48
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
49
+ assert entry is not None
50
+ assert WRITE_TOOL_NAME in entry.applicable_tool_names
51
+ assert EDIT_TOOL_NAME in entry.applicable_tool_names
52
+
53
+
54
+ def test_duplicate_rmtree_helper_blocker_is_blocking() -> None:
55
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
56
+ assert entry is not None
57
+ assert entry.is_blocking is True
58
+
59
+
60
+ def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
61
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
62
+ assert entry is not None
63
+ assert entry.native_module_name is None
64
+
65
+
66
+ def test_windows_rmtree_blocker_still_registered() -> None:
67
+ entry = _entry_for("blocking/windows_rmtree_blocker.py")
68
+ assert entry is not None
69
+ assert entry.applicable_tool_names == ALL_WRITE_AND_EDIT_TOOL_NAMES
70
+
71
+
72
+ def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
73
+ crash_result = HostedHookResult(
74
+ exit_code=0,
75
+ captured_stdout="",
76
+ did_crash=True,
77
+ is_blocking=True,
78
+ )
79
+ decision = aggregate_hosted_hook_results([crash_result])
80
+ assert decision.should_deny
81
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
82
+
83
+
84
+ def test_every_native_module_exposes_a_callable_evaluate() -> None:
85
+ nativized_entries = [
86
+ each_entry
87
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
88
+ if each_entry.native_module_name is not None
89
+ ]
90
+ assert nativized_entries, (
91
+ "the roster must carry at least one nativized hook for this test to lock the contract"
92
+ )
93
+ for each_entry in nativized_entries:
94
+ native_module = importlib.import_module(each_entry.native_module_name)
95
+ evaluate_function = getattr(native_module, "evaluate", None)
96
+ assert callable(evaluate_function), (
97
+ f"{each_entry.native_module_name} must expose a callable named evaluate, "
98
+ "matching the native_module_name docstring contract"
99
+ )
@@ -0,0 +1,39 @@
1
+ """Tests for the shared strip_code_and_quotes helper."""
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ from hooks_constants.text_stripping import strip_code_and_quotes
11
+
12
+
13
+ def test_removes_fenced_code_block() -> None:
14
+ text = "before\n```python\nshould I run this?\n```\nafter"
15
+ stripped = strip_code_and_quotes(text)
16
+ assert "should I run this?" not in stripped
17
+ assert "before" in stripped
18
+ assert "after" in stripped
19
+
20
+
21
+ def test_removes_inline_code_span() -> None:
22
+ text = "the function `would you like` is named oddly"
23
+ stripped = strip_code_and_quotes(text)
24
+ assert "would you like" not in stripped
25
+ assert "the function" in stripped
26
+ assert "is named oddly" in stripped
27
+
28
+
29
+ def test_removes_leading_blockquote_lines() -> None:
30
+ text = "real line\n> should I proceed?\nfinal line"
31
+ stripped = strip_code_and_quotes(text)
32
+ assert "should I proceed?" not in stripped
33
+ assert "real line" in stripped
34
+ assert "final line" in stripped
35
+
36
+
37
+ def test_leaves_plain_prose_unchanged() -> None:
38
+ text = "This sentence carries no code or quotes."
39
+ assert strip_code_and_quotes(text) == text
@@ -0,0 +1,36 @@
1
+ """Shared text-stripping helper for the Stop-hook prose blockers.
2
+
3
+ Several Stop hooks judge the prose of an assistant message and must ignore
4
+ fenced code blocks, inline code spans, and leading blockquotes so a phrase that
5
+ appears only inside code or a quote never trips the detector. The stripping
6
+ logic is identical across those blockers, so it lives here once and is imported
7
+ from each.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ __all__ = [
15
+ "strip_code_and_quotes",
16
+ ]
17
+
18
+ CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
19
+ INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
20
+ QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
21
+
22
+
23
+ def strip_code_and_quotes(text: str) -> str:
24
+ """Remove fenced code blocks, inline code, and blockquotes from prose.
25
+
26
+ Args:
27
+ text: The raw assistant message to clean of code and quoted lines.
28
+
29
+ Returns:
30
+ The text with every fenced code block, inline code span, and leading
31
+ blockquote line removed, so only the prose a reader sees remains.
32
+ """
33
+ text = CODE_BLOCK_PATTERN.sub("", text)
34
+ text = INLINE_CODE_PATTERN.sub("", text)
35
+ text = QUOTED_BLOCK_PATTERN.sub("", text)
36
+ return text
@@ -9,6 +9,7 @@ PostToolUse hooks that validate code quality after Claude writes or edits a file
9
9
  | `mypy_validator.py` | PostToolUse (Write/Edit on `.py` files) | Runs mypy on the written file and blocks (via PostToolUse block decision) when type errors are found — catches missing attributes, wrong signatures, type mismatches, and import errors |
10
10
  | `hook_format_validator.py` | PostToolUse | Validates that a hook script's output JSON matches the expected Claude Code hook-output schema |
11
11
  | `test_mypy_validator.py` | — | Tests for `mypy_validator.py` |
12
+ | `test_hook_format_validator.py` | — | Tests for `hook_format_validator.py` |
12
13
 
13
14
  ## Conventions
14
15
 
@@ -33,6 +33,7 @@ if _hooks_directory not in sys.path:
33
33
  from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402
34
34
  ALL_POST_HOSTED_HOOK_ENTRIES,
35
35
  BLOCK_DECISION,
36
+ BLOCKING_CRASH_DENY_REASON,
36
37
  DECISION_KEY,
37
38
  EMPTY_REASON_BLOCK_FALLBACK,
38
39
  HOOK_EVENT_NAME,
@@ -197,7 +198,6 @@ def aggregate_post_hosted_hook_results(
197
198
  A PostDispatcherDecision with the aggregated allow-or-block signal,
198
199
  all block reasons, and all non-block stdout.
199
200
  """
200
- blocking_crash_reason = "[dispatcher] hook crash in blocking hook — write blocked for safety"
201
201
  all_block_reasons: list[str] = []
202
202
  all_non_block_stdout: list[str] = []
203
203
 
@@ -206,7 +206,7 @@ def aggregate_post_hosted_hook_results(
206
206
  if is_block:
207
207
  all_block_reasons.append(block_reason if block_reason else EMPTY_REASON_BLOCK_FALLBACK)
208
208
  elif each_result.did_crash and each_result.is_blocking:
209
- all_block_reasons.append(blocking_crash_reason)
209
+ all_block_reasons.append(BLOCKING_CRASH_DENY_REASON)
210
210
  else:
211
211
  non_block_text = each_result.captured_stdout.strip()
212
212
  if non_block_text:
@@ -94,7 +94,7 @@ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) ->
94
94
  assert command[-1] == "package/module.py"
95
95
 
96
96
 
97
- def test_build_mypy_command_omits_config_file_when_absent(tmp_path: Path) -> None:
97
+ def test_build_mypy_command_omits_config_file_when_absent() -> None:
98
98
  validator = _load_validator()
99
99
 
100
100
  command = validator.build_mypy_command("package/module.py", None)
@@ -39,6 +39,7 @@ from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402, I00
39
39
  from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402, I001
40
40
  ALL_POST_HOSTED_HOOK_ENTRIES,
41
41
  BLOCK_DECISION,
42
+ BLOCKING_CRASH_DENY_REASON,
42
43
  EMPTY_REASON_BLOCK_FALLBACK,
43
44
  PLUGIN_ROOT_PLACEHOLDER,
44
45
  PostHostedHookEntry,
@@ -608,3 +609,8 @@ def test_blocking_hook_crash_surfaces_a_block() -> None:
608
609
  "The block reason from a blocking hook crash must reference the dispatcher.\n"
609
610
  f"Got: {aggregated_decision.all_block_reasons[0]!r}"
610
611
  )
612
+ assert BLOCKING_CRASH_DENY_REASON in aggregated_decision.all_block_reasons, (
613
+ "The block reason from a blocking hook crash must be the "
614
+ "BLOCKING_CRASH_DENY_REASON constant.\n"
615
+ f"Got: {aggregated_decision.all_block_reasons!r}"
616
+ )
@@ -79,11 +79,14 @@ def has_prettier_config(file_path: str) -> bool:
79
79
  def budgeted_python_format_seconds() -> int:
80
80
  """Return the wall-clock budget for the two-subprocess happy path.
81
81
 
82
- The Python branch breaks out of each loop the moment a command runs, so
83
- the common case spends one fix subprocess plus one format subprocess. This
84
- is a budget for that assumed path, not a guaranteed upper bound: when a
85
- command is missing or times out the loops fall through to the next entry,
86
- so a degraded run can spend more than this budget.
82
+ The fix loop breaks on the first command that runs whether it returns zero
83
+ or non-zero or on a timeout, and continues to the next command only when a
84
+ command is missing (FileNotFoundError). The format loop breaks only on a
85
+ returncode of zero or on a timeout, and continues on a non-zero return or a
86
+ missing command. The common case spends one fix subprocess plus one format
87
+ subprocess. This is a budget for that assumed path, not a guaranteed upper
88
+ bound: when commands are missing or time out the loops can spend more than
89
+ this budget.
87
90
  """
88
91
  fix_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
89
92
  format_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
@@ -105,6 +105,39 @@ class TestRuffFixOnNewFiles:
105
105
  assert completed_hook.returncode == 0
106
106
  assert "import os" in edited_file.read_text(encoding="utf-8")
107
107
 
108
+ def should_leave_tracked_python_file_arriving_through_write_untouched(
109
+ self, git_repository: Path
110
+ ) -> None:
111
+ tracked_file = git_repository / "tracked.py"
112
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
113
+ subprocess.run(
114
+ ["git", "add", "tracked.py"],
115
+ cwd=git_repository,
116
+ capture_output=True,
117
+ check=True,
118
+ )
119
+
120
+ completed_hook = _run_hook("Write", tracked_file)
121
+
122
+ assert completed_hook.returncode == 0
123
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
124
+
125
+
126
+ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> None:
127
+ tracked_file = git_repository / "tracked_module.py"
128
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
129
+ subprocess.run(
130
+ ["git", "add", "tracked_module.py"],
131
+ cwd=git_repository,
132
+ capture_output=True,
133
+ check=True,
134
+ )
135
+
136
+ completed_hook = _run_hook("Write", tracked_file)
137
+
138
+ assert completed_hook.returncode == 0
139
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
140
+
108
141
 
109
142
  def _load_auto_formatter_module() -> object:
110
143
  module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.74.0",
3
+ "version": "1.75.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,3 +5,5 @@ Never call `shutil.rmtree` with `ignore_errors=True` — Windows `ReadOnly` file
5
5
  In Node, call `mkdirSync(targetPath, { recursive: true })` on possibly-existing paths — `ReadOnly` directories break the non-recursive form. When the call must be non-recursive, strip the attribute first (`(Get-Item $path -Force).Attributes = "Directory"` / `os.chmod(path, stat.S_IWRITE)`).
6
6
 
7
7
  The `windows_rmtree_blocker.py` PreToolUse hook (Write/Edit/Bash) blocks the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code.
8
+
9
+ Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first — a fix lands in one and the other keeps the bug (CODE_RULES.md section 3, Reuse before create). The `duplicate_rmtree_helper_blocker.py` PreToolUse hook (Write/Edit) blocks a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` gate, which a copy between two distant packages slips past.
@@ -266,9 +266,12 @@ describes the narrowest rm auto-allow path — a standalone Bash call whose targ
266
266
  resolves inside the ephemeral namespace (`/tmp`, `/temp`, the OS temp root, or the
267
267
  run worktree) — and a compound path that accepts an rm joined with benign
268
268
  reporting segments when every rm target is an absolute ephemeral path. Both of
269
- those paths fail closed on `$(...)` substitution, backtick subshells, and any `$`
270
- in the target including `$CLAUDE_JOB_DIR` so neither resolves an environment
271
- variable. A third, broad path matches only when the command itself declares an
269
+ those paths fail closed on `$(...)` substitution and backtick subshells. The
270
+ compound path also fails closed on any `$` in the target — including
271
+ `$CLAUDE_JOB_DIR`. The standalone path declines a `$`-bearing target only when
272
+ the literal path is not already under an ephemeral root, so it does not by
273
+ itself stop a `$VAR` that expands inside an ephemeral root. A third, broad path
274
+ matches only when the command itself declares an
272
275
  ephemeral working directory (it `cd`s into one, or runs under one): that
273
276
  cwd-scoped path resolves the target against the declared cwd, fails closed on
274
277
  `$(...)`, backticks, and unknown variables, and resolves the known temporary
@@ -31,6 +31,13 @@ skill still runs teardown (revoke permissions, final report).
31
31
  cannot confirm the PR left draft state (`gh pr ready` errored, or the draft
32
32
  re-query still reports true). The workflow does not report `converged: true`;
33
33
  the run ends with a `blocker` naming the failed ready transition.
34
+ - **Clean-audit post blocked** — every review lens is clean on HEAD, but the
35
+ CLEAN bugteam review cannot be posted (the `post_audit_thread.py` post is
36
+ denied, errors, or its agent dies). The convergence gate's bugteam-review
37
+ check can never pass without that CLEAN review, so the run stops rather than
38
+ re-converge to the iteration cap. The `blocker` names the post failure and the
39
+ HEAD. Unblock by allowing `post_audit_thread.py` with a Bash permission rule,
40
+ or post the CLEAN review by hand, then re-run.
34
41
 
35
42
  ## Not a blocker (the run continues)
36
43