claude-dev-env 1.72.0 → 1.74.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.
- package/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Constants for the package-inventory stale-entry blocker.
|
|
2
|
+
|
|
3
|
+
A package directory documents its own files in a sibling inventory document —
|
|
4
|
+
a ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list — whose entries
|
|
5
|
+
name each file in backticks. When a new production code file lands in that
|
|
6
|
+
directory and the inventory carries no entry naming it, the inventory disagrees
|
|
7
|
+
with the directory on the package's file set, and a reader trusting the
|
|
8
|
+
inventory to map the directory misses the new file. This module holds the
|
|
9
|
+
inventory document names, the production code extensions that earn an inventory
|
|
10
|
+
entry, the backtick pattern that finds an inventory's named files, the code-fence
|
|
11
|
+
pattern that marks lines to skip, the glob-metacharacter pattern that rejects
|
|
12
|
+
pattern tokens, the non-filename pattern that rejects command-example and
|
|
13
|
+
path-bearing prose spans, the minimum inventory size that marks a document as a
|
|
14
|
+
maintained inventory, the filenames exempt from an entry, the scan budget, and
|
|
15
|
+
the block-message text the hook emits.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ALL_INVENTORY_DOCUMENT_NAMES",
|
|
22
|
+
"ALL_PRODUCTION_CODE_EXTENSIONS",
|
|
23
|
+
"PYTHON_FILE_EXTENSION",
|
|
24
|
+
"ALL_TEST_FILE_MARKERS",
|
|
25
|
+
"BACKTICK_TOKEN_PATTERN",
|
|
26
|
+
"CODE_FENCE_PATTERN",
|
|
27
|
+
"GLOB_METACHARACTER_PATTERN",
|
|
28
|
+
"NON_FILENAME_TOKEN_PATTERN",
|
|
29
|
+
"MINIMUM_INVENTORY_ENTRY_COUNT",
|
|
30
|
+
"ALL_EXEMPT_BASENAMES",
|
|
31
|
+
"ALL_EXEMPT_DIRECTORY_NAMES",
|
|
32
|
+
"MAX_INVENTORY_FILE_BYTES",
|
|
33
|
+
"STALE_INVENTORY_MESSAGE_TEMPLATE",
|
|
34
|
+
"STALE_INVENTORY_SYSTEM_MESSAGE",
|
|
35
|
+
"STALE_INVENTORY_ADDITIONAL_CONTEXT",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset({"README.md", "CLAUDE.md"})
|
|
39
|
+
|
|
40
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
41
|
+
|
|
42
|
+
ALL_TEST_FILE_MARKERS: tuple[str, ...] = (".spec.", ".test.")
|
|
43
|
+
|
|
44
|
+
ALL_PRODUCTION_CODE_EXTENSIONS: frozenset[str] = frozenset(
|
|
45
|
+
{
|
|
46
|
+
".py",
|
|
47
|
+
".mjs",
|
|
48
|
+
".js",
|
|
49
|
+
".ts",
|
|
50
|
+
".ps1",
|
|
51
|
+
".sh",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
BACKTICK_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
|
|
56
|
+
|
|
57
|
+
CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
|
|
58
|
+
|
|
59
|
+
GLOB_METACHARACTER_PATTERN: re.Pattern[str] = re.compile(r"[*?{}\[\]]")
|
|
60
|
+
|
|
61
|
+
NON_FILENAME_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"[\s:$<>]")
|
|
62
|
+
|
|
63
|
+
MINIMUM_INVENTORY_ENTRY_COUNT: int = 2
|
|
64
|
+
|
|
65
|
+
ALL_EXEMPT_BASENAMES: frozenset[str] = frozenset(
|
|
66
|
+
{
|
|
67
|
+
"__init__.py",
|
|
68
|
+
"conftest.py",
|
|
69
|
+
"setup.py",
|
|
70
|
+
"_path_setup.py",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
ALL_EXEMPT_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"config",
|
|
77
|
+
"tests",
|
|
78
|
+
"__pycache__",
|
|
79
|
+
".git",
|
|
80
|
+
"node_modules",
|
|
81
|
+
".pytest_cache",
|
|
82
|
+
".ruff_cache",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
MAX_INVENTORY_FILE_BYTES: int = 200_000
|
|
87
|
+
|
|
88
|
+
STALE_INVENTORY_MESSAGE_TEMPLATE: str = (
|
|
89
|
+
"New production file `{filename}` lands in {directory}, whose inventory "
|
|
90
|
+
"document(s) ({inventories}) name {entry_count} sibling files but no entry "
|
|
91
|
+
"for `{filename}`. A package inventory names every production file in its "
|
|
92
|
+
"directory; a new file the inventory omits leaves the inventory and the "
|
|
93
|
+
"directory disagreeing on the package's file set. Add an entry naming "
|
|
94
|
+
"`{filename}` to the inventory in this same change."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
STALE_INVENTORY_SYSTEM_MESSAGE: str = (
|
|
98
|
+
"New production file is absent from its package inventory (README.md / "
|
|
99
|
+
"CLAUDE.md) - add the inventory entry in this same change"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
STALE_INVENTORY_ADDITIONAL_CONTEXT: str = (
|
|
103
|
+
"A package directory whose README.md or CLAUDE.md lists its files in "
|
|
104
|
+
"backticks is a maintained inventory of the package's file set. A new "
|
|
105
|
+
"production code file (.py, .mjs, .js, .ts, .ps1, .sh) in that directory "
|
|
106
|
+
"carries one inventory entry naming it. Add a row to the README.md table or "
|
|
107
|
+
"a bullet to the CLAUDE.md list naming this file, describing what it does, "
|
|
108
|
+
"in the same change that creates the file. Exempt files (no entry needed): "
|
|
109
|
+
"__init__.py, conftest.py, setup.py, _path_setup.py, files under config/ or "
|
|
110
|
+
"tests/, and test files (test_*.py, *_test.py, *.spec.*, *.test.*)."
|
|
111
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Constants for the PostToolUse dispatcher that hosts the after-write hooks.
|
|
2
|
+
|
|
3
|
+
Holds the ordered hosted-hook list with each hook's extra command-line
|
|
4
|
+
arguments and blocking flag, the PostToolUse block-decision string and key,
|
|
5
|
+
and the hook-event name. The dispatcher imports each of these by name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BLOCK_DECISION",
|
|
14
|
+
"DECISION_KEY",
|
|
15
|
+
"REASON_KEY",
|
|
16
|
+
"HOOK_EVENT_NAME",
|
|
17
|
+
"EMPTY_REASON_BLOCK_FALLBACK",
|
|
18
|
+
"PLUGIN_ROOT_PLACEHOLDER",
|
|
19
|
+
"PostHostedHookEntry",
|
|
20
|
+
"ALL_POST_HOSTED_HOOK_ENTRIES",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
BLOCK_DECISION = "block"
|
|
24
|
+
DECISION_KEY = "decision"
|
|
25
|
+
REASON_KEY = "reason"
|
|
26
|
+
HOOK_EVENT_NAME = "PostToolUse"
|
|
27
|
+
EMPTY_REASON_BLOCK_FALLBACK = "[dispatcher] hook blocked with no reason — write blocked"
|
|
28
|
+
|
|
29
|
+
PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PostHostedHookEntry:
|
|
34
|
+
"""A single hosted PostToolUse hook with its run-time arguments and flags.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
script_relative_path: Hook path relative to the hooks/ directory.
|
|
38
|
+
extra_argument_relative_paths: Command-line arguments the live entry
|
|
39
|
+
passes after the script path, each a path relative to the plugin
|
|
40
|
+
root (the hooks/ parent). The dispatcher resolves each to an
|
|
41
|
+
absolute path and exposes them as the hook's argv tail, so a hook
|
|
42
|
+
that reads sys.argv[1] resolves the same path the live entry gives
|
|
43
|
+
it. An empty tuple means the live entry passes no extra arguments.
|
|
44
|
+
is_blocking: True when this hook can emit a block decision and a crash
|
|
45
|
+
should surface a blocking signal; False when the hook only performs
|
|
46
|
+
a side effect and never blocks.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
script_relative_path: str
|
|
50
|
+
extra_argument_relative_paths: tuple[str, ...] = field(default_factory=tuple)
|
|
51
|
+
is_blocking: bool = field(default=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
ALL_POST_HOSTED_HOOK_ENTRIES: tuple[PostHostedHookEntry, ...] = (
|
|
55
|
+
PostHostedHookEntry(
|
|
56
|
+
script_relative_path="validation/mypy_validator.py",
|
|
57
|
+
is_blocking=True,
|
|
58
|
+
),
|
|
59
|
+
PostHostedHookEntry(
|
|
60
|
+
script_relative_path="workflow/auto_formatter.py",
|
|
61
|
+
is_blocking=False,
|
|
62
|
+
),
|
|
63
|
+
PostHostedHookEntry(
|
|
64
|
+
script_relative_path="workflow/doc_gist_auto_publish.py",
|
|
65
|
+
extra_argument_relative_paths=(PLUGIN_ROOT_PLACEHOLDER,),
|
|
66
|
+
is_blocking=False,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Constants for the PreToolUse dispatcher that hosts Write/Edit/MultiEdit hooks.
|
|
2
|
+
|
|
3
|
+
Holds the ordered hosted-hook list with per-hook applicable-tool sets, the
|
|
4
|
+
special exit codes, the deny decision string, and the hook-event name. The
|
|
5
|
+
dispatcher imports each of these by name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DENY_DECISION",
|
|
14
|
+
"ALLOW_DECISION",
|
|
15
|
+
"HOOK_EVENT_NAME",
|
|
16
|
+
"BLOCKING_CRASH_EXIT_CODE",
|
|
17
|
+
"EXIT_CODE_TWO_DENY_REASON",
|
|
18
|
+
"WRITE_TOOL_NAME",
|
|
19
|
+
"EDIT_TOOL_NAME",
|
|
20
|
+
"MULTI_EDIT_TOOL_NAME",
|
|
21
|
+
"ALL_WRITE_AND_EDIT_TOOL_NAMES",
|
|
22
|
+
"ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
|
|
23
|
+
"STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
|
|
24
|
+
"PLAIN_LANGUAGE_BLOCKER_MODULE_NAME",
|
|
25
|
+
"HostedHookEntry",
|
|
26
|
+
"ALL_HOSTED_HOOK_ENTRIES",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
DENY_DECISION = "deny"
|
|
30
|
+
ALLOW_DECISION = "allow"
|
|
31
|
+
HOOK_EVENT_NAME = "PreToolUse"
|
|
32
|
+
BLOCKING_CRASH_EXIT_CODE = 2
|
|
33
|
+
EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
|
|
34
|
+
|
|
35
|
+
WRITE_TOOL_NAME = "Write"
|
|
36
|
+
EDIT_TOOL_NAME = "Edit"
|
|
37
|
+
MULTI_EDIT_TOOL_NAME = "MultiEdit"
|
|
38
|
+
|
|
39
|
+
ALL_WRITE_AND_EDIT_TOOL_NAMES: frozenset[str] = frozenset({WRITE_TOOL_NAME, EDIT_TOOL_NAME})
|
|
40
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
|
|
41
|
+
{WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
STATE_DESCRIPTION_BLOCKER_MODULE_NAME = "state_description_blocker"
|
|
46
|
+
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME = "plain_language_blocker"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class HostedHookEntry:
|
|
51
|
+
"""A single hosted hook with its applicable-tools constraint and blocking flag.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
script_relative_path: Hook path relative to the hooks/ directory.
|
|
55
|
+
applicable_tool_names: Tool names this hook applies to. The dispatcher
|
|
56
|
+
skips the hook when the payload's tool is not in this set.
|
|
57
|
+
is_blocking: True when a crash surfaces a blocking signal; False when the
|
|
58
|
+
hook is advisory and a crash stays silent.
|
|
59
|
+
native_module_name: The importable module name whose evaluate function
|
|
60
|
+
the dispatcher calls in-process for this hook, or None when the hook
|
|
61
|
+
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
|
+
"""
|
|
65
|
+
|
|
66
|
+
script_relative_path: str
|
|
67
|
+
applicable_tool_names: frozenset[str]
|
|
68
|
+
is_blocking: bool = field(default=True)
|
|
69
|
+
native_module_name: str | None = field(default=None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
73
|
+
HostedHookEntry(
|
|
74
|
+
script_relative_path="blocking/write_existing_file_blocker.py",
|
|
75
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
76
|
+
),
|
|
77
|
+
HostedHookEntry(
|
|
78
|
+
script_relative_path="blocking/sensitive_file_protector.py",
|
|
79
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
80
|
+
),
|
|
81
|
+
HostedHookEntry(
|
|
82
|
+
script_relative_path="validation/hook_format_validator.py",
|
|
83
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
84
|
+
),
|
|
85
|
+
HostedHookEntry(
|
|
86
|
+
script_relative_path="blocking/code_rules_enforcer.py",
|
|
87
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
88
|
+
),
|
|
89
|
+
HostedHookEntry(
|
|
90
|
+
script_relative_path="blocking/tdd_enforcer.py",
|
|
91
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
92
|
+
),
|
|
93
|
+
HostedHookEntry(
|
|
94
|
+
script_relative_path="blocking/windows_rmtree_blocker.py",
|
|
95
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
96
|
+
),
|
|
97
|
+
HostedHookEntry(
|
|
98
|
+
script_relative_path="blocking/state_description_blocker.py",
|
|
99
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
100
|
+
native_module_name=STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
101
|
+
),
|
|
102
|
+
HostedHookEntry(
|
|
103
|
+
script_relative_path="blocking/subprocess_budget_completeness.py",
|
|
104
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
105
|
+
),
|
|
106
|
+
HostedHookEntry(
|
|
107
|
+
script_relative_path="blocking/hook_prose_detector_consistency.py",
|
|
108
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
109
|
+
),
|
|
110
|
+
HostedHookEntry(
|
|
111
|
+
script_relative_path="blocking/verified_commit_message_accuracy_blocker.py",
|
|
112
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
113
|
+
),
|
|
114
|
+
HostedHookEntry(
|
|
115
|
+
script_relative_path="blocking/workflow_substitution_slot_blocker.py",
|
|
116
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
117
|
+
),
|
|
118
|
+
HostedHookEntry(
|
|
119
|
+
script_relative_path="blocking/claude_md_orphan_file_blocker.py",
|
|
120
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
121
|
+
),
|
|
122
|
+
HostedHookEntry(
|
|
123
|
+
script_relative_path="blocking/package_inventory_stale_blocker.py",
|
|
124
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
125
|
+
),
|
|
126
|
+
HostedHookEntry(
|
|
127
|
+
script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
|
|
128
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
129
|
+
),
|
|
130
|
+
HostedHookEntry(
|
|
131
|
+
script_relative_path="blocking/open_questions_in_plans_blocker.py",
|
|
132
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
133
|
+
),
|
|
134
|
+
HostedHookEntry(
|
|
135
|
+
script_relative_path="blocking/docstring_rule_gate_count_blocker.py",
|
|
136
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
137
|
+
),
|
|
138
|
+
HostedHookEntry(
|
|
139
|
+
script_relative_path="blocking/plain_language_blocker.py",
|
|
140
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
141
|
+
native_module_name=PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Constants for the pytest unregistered-test-directory blocker.
|
|
2
|
+
|
|
3
|
+
A package whose ``pyproject.toml`` declares ``[tool.pytest.ini_options]`` with an
|
|
4
|
+
explicit ``testpaths`` list runs only the directories that list names. A
|
|
5
|
+
``test_*.py`` file written into a directory that no ``testpaths`` entry covers is
|
|
6
|
+
collected by no default ``pytest`` run, so the test silently never executes and a
|
|
7
|
+
regression in the code it guards passes the standard suite undetected. This
|
|
8
|
+
module holds the marker filename that anchors a pytest package, the key name
|
|
9
|
+
that identifies an explicit ``testpaths`` allowlist, the test-file basename
|
|
10
|
+
pattern, the package-root entry tokens and glob metacharacters that classify a
|
|
11
|
+
``testpaths`` entry, the directory names the upward search prunes, the search
|
|
12
|
+
budget, and the block-message text the hook emits.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PYPROJECT_FILENAME",
|
|
19
|
+
"TESTPATHS_KEY",
|
|
20
|
+
"TEST_FILE_BASENAME_PATTERN",
|
|
21
|
+
"PACKAGE_ROOT_ENTRY",
|
|
22
|
+
"PACKAGE_ROOT_ENTRY_PREFIX",
|
|
23
|
+
"GLOB_METACHARACTERS",
|
|
24
|
+
"ALL_PRUNED_PARENT_DIRECTORY_NAMES",
|
|
25
|
+
"MAX_PARENT_DIRECTORIES_SEARCHED",
|
|
26
|
+
"UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE",
|
|
27
|
+
"UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE",
|
|
28
|
+
"UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
PYPROJECT_FILENAME: str = "pyproject.toml"
|
|
32
|
+
|
|
33
|
+
TESTPATHS_KEY: str = "testpaths"
|
|
34
|
+
|
|
35
|
+
TEST_FILE_BASENAME_PATTERN: re.Pattern[str] = re.compile(r"^test_.+\.py$")
|
|
36
|
+
|
|
37
|
+
PACKAGE_ROOT_ENTRY: str = "."
|
|
38
|
+
|
|
39
|
+
PACKAGE_ROOT_ENTRY_PREFIX: str = "./"
|
|
40
|
+
|
|
41
|
+
GLOB_METACHARACTERS: frozenset[str] = frozenset({"*", "?", "["})
|
|
42
|
+
|
|
43
|
+
ALL_PRUNED_PARENT_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
".git",
|
|
46
|
+
"__pycache__",
|
|
47
|
+
"node_modules",
|
|
48
|
+
".pytest_cache",
|
|
49
|
+
".ruff_cache",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
MAX_PARENT_DIRECTORIES_SEARCHED: int = 40
|
|
54
|
+
|
|
55
|
+
UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE: str = (
|
|
56
|
+
"Test file {test_file} lands in a directory that the pytest config at "
|
|
57
|
+
"{pyproject} does not collect. That pyproject declares an explicit testpaths "
|
|
58
|
+
"allowlist, and no entry covers {test_directory} (relative to the package "
|
|
59
|
+
"root). A default `pytest` run from the package root never collects this file, "
|
|
60
|
+
"so the test silently never runs and a regression it would catch passes the "
|
|
61
|
+
"suite undetected. Add the directory to the testpaths list in {pyproject} "
|
|
62
|
+
"(for example `{suggested_entry}`) in the same change that adds the test."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE: str = (
|
|
66
|
+
"test file lands outside the pytest testpaths allowlist - add its directory to "
|
|
67
|
+
"testpaths so the default suite collects it"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT: str = (
|
|
71
|
+
"When a package's pyproject.toml declares [tool.pytest.ini_options] with an "
|
|
72
|
+
"explicit testpaths list, that list is the complete set of directories a "
|
|
73
|
+
"default pytest run collects. A test_*.py file written into a directory no "
|
|
74
|
+
"testpaths entry covers is collected by nobody: the default run skips it and "
|
|
75
|
+
"the regression it guards goes unnoticed. To resolve:\n"
|
|
76
|
+
" - add the test file's directory (relative to the package root) to the "
|
|
77
|
+
"testpaths list in pyproject.toml, or\n"
|
|
78
|
+
" - move the test under a directory the testpaths list already covers."
|
|
79
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Configuration constants for the send_user_file_open_locally_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
TOOL_NAME: str = "SendUserFile"
|
|
4
|
+
|
|
5
|
+
PROACTIVE_STATUS: str = "proactive"
|
|
6
|
+
|
|
7
|
+
CORRECTIVE_MESSAGE: str = (
|
|
8
|
+
"BLOCKED [open-locally]: SendUserFile attaches a file to the session, which "
|
|
9
|
+
"does not let the user see it while they are at the terminal. Open the file on "
|
|
10
|
+
"screen in its own viewer:\n"
|
|
11
|
+
" Start-Process pwsh -WindowStyle Hidden -ArgumentList "
|
|
12
|
+
"'-NoProfile','-File',\"$HOME\\.claude\\scripts\\Show-Asset.ps1\","
|
|
13
|
+
"'<path 1>','<path 2>'\n"
|
|
14
|
+
"Show-Asset.ps1 sizes each image window to the image and opens every other file "
|
|
15
|
+
"type in its default app. Pass every path the user named.\n"
|
|
16
|
+
"The one allowed attach is a phone push: when the user has stepped away and you "
|
|
17
|
+
'want the file to reach their phone, call SendUserFile with status "proactive".'
|
|
18
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Regression tests proving the dispatcher constants docstrings clear the O6 gate.
|
|
2
|
+
|
|
3
|
+
The PreToolUse and PostToolUse dispatcher constants modules each carry a module
|
|
4
|
+
docstring stating what the module centralizes. Neither docstring may assert that
|
|
5
|
+
no literals appear inline in its companion dispatcher script — that completeness
|
|
6
|
+
claim about a sibling file is the deterministic Category O6 drift the enforcer's
|
|
7
|
+
check_docstring_no_inline_literal_claim blocks. These tests load the real
|
|
8
|
+
modules' source and assert the check returns no issues for either.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_enforcer_module() -> ModuleType:
|
|
19
|
+
enforcer_path = Path(__file__).resolve().parent.parent / "blocking" / "code_rules_enforcer.py"
|
|
20
|
+
enforcer_spec = importlib.util.spec_from_file_location("code_rules_enforcer", enforcer_path)
|
|
21
|
+
assert enforcer_spec is not None
|
|
22
|
+
assert enforcer_spec.loader is not None
|
|
23
|
+
enforcer_module = importlib.util.module_from_spec(enforcer_spec)
|
|
24
|
+
enforcer_spec.loader.exec_module(enforcer_module)
|
|
25
|
+
return enforcer_module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_issues_for(module_filename: str) -> list[str]:
|
|
32
|
+
module_path = Path(__file__).resolve().parent / module_filename
|
|
33
|
+
module_source = module_path.read_text(encoding="utf-8")
|
|
34
|
+
return code_rules_enforcer.check_docstring_no_inline_literal_claim(
|
|
35
|
+
module_source, str(module_path)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_pre_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
|
|
40
|
+
assert _check_issues_for("pre_tool_use_dispatcher_constants.py") == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_post_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
|
|
44
|
+
assert _check_issues_for("post_tool_use_dispatcher_constants.py") == []
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for the shared hook block logger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
15
|
+
|
|
16
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_log_hook_block_writes_parseable_json_line(tmp_path: Path) -> None:
|
|
20
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
21
|
+
log_hook_block(
|
|
22
|
+
calling_hook_name="test_hook.py",
|
|
23
|
+
hook_event="PreToolUse",
|
|
24
|
+
block_reason="test block reason",
|
|
25
|
+
tool_name="Bash",
|
|
26
|
+
offending_input_preview="echo hello",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
30
|
+
assert log_path.exists()
|
|
31
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
32
|
+
parsed = json.loads(line)
|
|
33
|
+
|
|
34
|
+
assert "timestamp" in parsed
|
|
35
|
+
assert parsed["hook"] == "test_hook.py"
|
|
36
|
+
assert parsed["event"] == "PreToolUse"
|
|
37
|
+
assert parsed["tool"] == "Bash"
|
|
38
|
+
assert parsed["reason"] == "test block reason"
|
|
39
|
+
assert parsed["preview"] == "echo hello"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_log_hook_block_creates_logs_directory(tmp_path: Path) -> None:
|
|
43
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
44
|
+
log_hook_block(
|
|
45
|
+
calling_hook_name="some_hook.py",
|
|
46
|
+
hook_event="Stop",
|
|
47
|
+
block_reason="stop reason",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
51
|
+
assert log_path.exists()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_log_hook_block_omits_none_fields(tmp_path: Path) -> None:
|
|
55
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
56
|
+
log_hook_block(
|
|
57
|
+
calling_hook_name="minimal_hook.py",
|
|
58
|
+
hook_event="PreToolUse",
|
|
59
|
+
block_reason="some reason",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
63
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
64
|
+
parsed = json.loads(line)
|
|
65
|
+
assert "tool" not in parsed
|
|
66
|
+
assert "preview" not in parsed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_log_hook_block_appends_multiple_records(tmp_path: Path) -> None:
|
|
70
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
71
|
+
log_hook_block(
|
|
72
|
+
calling_hook_name="hook_a.py",
|
|
73
|
+
hook_event="PreToolUse",
|
|
74
|
+
block_reason="first",
|
|
75
|
+
)
|
|
76
|
+
log_hook_block(
|
|
77
|
+
calling_hook_name="hook_b.py",
|
|
78
|
+
hook_event="Stop",
|
|
79
|
+
block_reason="second",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
83
|
+
all_lines = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
84
|
+
assert len(all_lines) == 2
|
|
85
|
+
first_parsed = json.loads(all_lines[0])
|
|
86
|
+
second_parsed = json.loads(all_lines[1])
|
|
87
|
+
assert first_parsed["hook"] == "hook_a.py"
|
|
88
|
+
assert second_parsed["hook"] == "hook_b.py"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_log_hook_block_swallows_io_error_on_unwritable_log(tmp_path: Path) -> None:
|
|
92
|
+
logs_dir = tmp_path / ".claude" / "logs"
|
|
93
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
log_path = logs_dir / "hook-blocks.log"
|
|
95
|
+
log_path.write_text("", encoding="utf-8")
|
|
96
|
+
log_path.chmod(stat.S_IREAD)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
100
|
+
log_hook_block(
|
|
101
|
+
calling_hook_name="any_hook.py",
|
|
102
|
+
hook_event="PreToolUse",
|
|
103
|
+
block_reason="reason",
|
|
104
|
+
)
|
|
105
|
+
except OSError:
|
|
106
|
+
pytest.fail("log_hook_block raised OSError on unwritable log file")
|
|
107
|
+
finally:
|
|
108
|
+
log_path.chmod(stat.S_IREAD | stat.S_IWRITE)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_log_hook_block_swallows_runtime_error_when_home_unresolvable() -> None:
|
|
112
|
+
def raise_home_resolution_failure() -> Path:
|
|
113
|
+
raise RuntimeError("Could not determine home directory.")
|
|
114
|
+
|
|
115
|
+
with patch.object(Path, "home", side_effect=raise_home_resolution_failure):
|
|
116
|
+
try:
|
|
117
|
+
returned_nothing = log_hook_block(
|
|
118
|
+
calling_hook_name="any_hook.py",
|
|
119
|
+
hook_event="PreToolUse",
|
|
120
|
+
block_reason="reason",
|
|
121
|
+
)
|
|
122
|
+
except RuntimeError:
|
|
123
|
+
pytest.fail("log_hook_block raised RuntimeError when home was unresolvable")
|
|
124
|
+
|
|
125
|
+
assert returned_nothing is None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_log_hook_block_truncates_long_preview(tmp_path: Path) -> None:
|
|
129
|
+
long_input = "x" * 600
|
|
130
|
+
|
|
131
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
132
|
+
log_hook_block(
|
|
133
|
+
calling_hook_name="hook.py",
|
|
134
|
+
hook_event="PreToolUse",
|
|
135
|
+
block_reason="reason",
|
|
136
|
+
offending_input_preview=long_input,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
140
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
141
|
+
parsed = json.loads(line)
|
|
142
|
+
assert len(parsed["preview"]) <= 500
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_log_hook_block_timestamp_is_iso8601(tmp_path: Path) -> None:
|
|
146
|
+
before = datetime.datetime.now()
|
|
147
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
148
|
+
log_hook_block(
|
|
149
|
+
calling_hook_name="ts_hook.py",
|
|
150
|
+
hook_event="PreToolUse",
|
|
151
|
+
block_reason="ts test",
|
|
152
|
+
)
|
|
153
|
+
after = datetime.datetime.now()
|
|
154
|
+
|
|
155
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
156
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
157
|
+
parsed = json.loads(line)
|
|
158
|
+
parsed_timestamp = datetime.datetime.fromisoformat(parsed["timestamp"])
|
|
159
|
+
assert before <= parsed_timestamp <= after
|
|
@@ -4,6 +4,13 @@ from datetime import datetime
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
10
|
+
if _hooks_dir not in sys.path:
|
|
11
|
+
sys.path.insert(0, _hooks_dir)
|
|
12
|
+
|
|
13
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
7
14
|
|
|
8
15
|
AUDIT_LOG = os.path.expanduser("~/.claude/cache/config-change-audit.log")
|
|
9
16
|
# pragma: no-tdd-gate
|
|
@@ -68,6 +75,11 @@ def guard_hook_injection(file_path: str) -> None:
|
|
|
68
75
|
"decision": "block",
|
|
69
76
|
"reason": block_reason,
|
|
70
77
|
}
|
|
78
|
+
log_hook_block(
|
|
79
|
+
calling_hook_name="config_change_guard.py",
|
|
80
|
+
hook_event="ConfigChange",
|
|
81
|
+
block_reason=block_reason,
|
|
82
|
+
)
|
|
71
83
|
print(json.dumps(block_payload))
|
|
72
84
|
return
|
|
73
85
|
|
|
@@ -109,3 +109,26 @@ def test_non_user_settings_source_produces_no_output(tmp_path: Path) -> None:
|
|
|
109
109
|
assert hook_run.returncode == 0
|
|
110
110
|
assert hook_run.stderr.strip() == ""
|
|
111
111
|
assert hook_run.stdout.strip() == ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_block_logs_config_change_event(tmp_path: Path) -> None:
|
|
115
|
+
fake_home = tmp_path / "home"
|
|
116
|
+
fake_home.mkdir()
|
|
117
|
+
known_count_file = tmp_path / "known-hook-count.txt"
|
|
118
|
+
known_count_file.write_text("2")
|
|
119
|
+
settings_path = _make_settings_with_hook_count(5, tmp_path)
|
|
120
|
+
|
|
121
|
+
hook_run = _run_hook(
|
|
122
|
+
source="user_settings",
|
|
123
|
+
file_path=settings_path,
|
|
124
|
+
extra_env={
|
|
125
|
+
"KNOWN_HOOK_COUNT_FILE": str(known_count_file),
|
|
126
|
+
"HOME": str(fake_home),
|
|
127
|
+
"USERPROFILE": str(fake_home),
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert hook_run.returncode == 0
|
|
132
|
+
log_path = fake_home / ".claude" / "logs" / "hook-blocks.log"
|
|
133
|
+
logged_record = json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1])
|
|
134
|
+
assert logged_record["event"] == "ConfigChange"
|