claude-dev-env 1.73.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 +1 -1
- package/hooks/blocking/CLAUDE.md +3 -0
- 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 +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
- 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 +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -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 +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -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 +8 -8
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -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 +10 -1
- 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 +10 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- 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/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
- 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 +30 -1
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +22 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +18 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +2 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Constants for the docstring-rule gate-count staleness blocker.
|
|
2
|
+
|
|
3
|
+
The rule file ``docstring-prose-matches-implementation.md`` enumerates the
|
|
4
|
+
``check_docstring_*`` gate validators that cover deterministic slices of docstring
|
|
5
|
+
prose, both as a spelled-out count ("Three more gate validators", "four gated
|
|
6
|
+
slices") and as a backticked list of the validator names. When a new gate
|
|
7
|
+
validator is registered but the count word is left unchanged, the rule's stated
|
|
8
|
+
count drifts from the validators it actually names — the same companion-doc drift
|
|
9
|
+
the rule itself governs. This module holds the target rule basename, the
|
|
10
|
+
spelled-out-number lookup, the code-fence pattern that marks lines to skip, the
|
|
11
|
+
patterns that find the "<count> more gate validators" and "<count> gated slices"
|
|
12
|
+
count clauses and the backticked ``check_*`` validator names, the args-gate name,
|
|
13
|
+
the issue budget, and the block-message text the hook emits.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"TARGET_RULE_BASENAME",
|
|
20
|
+
"ALL_NUMBER_WORDS_BY_VALUE",
|
|
21
|
+
"CODE_FENCE_PATTERN",
|
|
22
|
+
"FREE_FORM_GATE_COUNT_PATTERN",
|
|
23
|
+
"TOTAL_GATED_SLICE_COUNT_PATTERN",
|
|
24
|
+
"GATE_VALIDATOR_NAME_PATTERN",
|
|
25
|
+
"ARGS_GATE_VALIDATOR_NAME",
|
|
26
|
+
"MAX_GATE_COUNT_ISSUES",
|
|
27
|
+
"GATE_COUNT_MESSAGE_TEMPLATE",
|
|
28
|
+
"GATE_COUNT_SYSTEM_MESSAGE",
|
|
29
|
+
"GATE_COUNT_ADDITIONAL_CONTEXT",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
TARGET_RULE_BASENAME: str = "docstring-prose-matches-implementation.md"
|
|
33
|
+
|
|
34
|
+
ALL_NUMBER_WORDS_BY_VALUE: dict[str, int] = {
|
|
35
|
+
"zero": 0,
|
|
36
|
+
"one": 1,
|
|
37
|
+
"two": 2,
|
|
38
|
+
"three": 3,
|
|
39
|
+
"four": 4,
|
|
40
|
+
"five": 5,
|
|
41
|
+
"six": 6,
|
|
42
|
+
"seven": 7,
|
|
43
|
+
"eight": 8,
|
|
44
|
+
"nine": 9,
|
|
45
|
+
"ten": 10,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
|
|
49
|
+
|
|
50
|
+
FREE_FORM_GATE_COUNT_PATTERN: re.Pattern[str] = re.compile(
|
|
51
|
+
r"\b([A-Za-z]+)\s+more\s+gate\s+validators\b",
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
TOTAL_GATED_SLICE_COUNT_PATTERN: re.Pattern[str] = re.compile(
|
|
56
|
+
r"\b([A-Za-z]+)\s+gated\s+slices\b",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
GATE_VALIDATOR_NAME_PATTERN: re.Pattern[str] = re.compile(r"`(check_[A-Za-z0-9_]+)`")
|
|
61
|
+
|
|
62
|
+
ARGS_GATE_VALIDATOR_NAME: str = "check_docstring_args_match_signature"
|
|
63
|
+
|
|
64
|
+
MAX_GATE_COUNT_ISSUES: int = 4
|
|
65
|
+
|
|
66
|
+
GATE_COUNT_MESSAGE_TEMPLATE: str = (
|
|
67
|
+
"{rule_basename} states '{stated_phrase}' ({stated_count}) but names "
|
|
68
|
+
"{named_count} distinct free-form gate validator(s) ({named_validators}). The "
|
|
69
|
+
"rule's spelled-out gate count drifts from the validators it enumerates — the "
|
|
70
|
+
"companion-doc-vs-implementation drift this rule governs. Update the count "
|
|
71
|
+
"word to {named_count} and the '... gated slices' total to {total_count} in "
|
|
72
|
+
"this same change, and name every gate validator the prose counts."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
GATE_COUNT_SYSTEM_MESSAGE: str = (
|
|
76
|
+
"Gate-validator count in docstring-prose-matches-implementation.md drifted "
|
|
77
|
+
"from the validators it names - update the count word in this same change"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
GATE_COUNT_ADDITIONAL_CONTEXT: str = (
|
|
81
|
+
"The rule docstring-prose-matches-implementation.md states a spelled-out "
|
|
82
|
+
"count of free-form docstring gate validators ('Four more gate validators') "
|
|
83
|
+
"and a total ('five gated slices'), then names each validator in backticks "
|
|
84
|
+
"(`check_docstring_fallback_branch_coverage`, ...). When a new "
|
|
85
|
+
"`check_docstring_*` gate is added, name it in the prose and bump both count "
|
|
86
|
+
"words: the 'N more gate validators' count equals the number of distinct "
|
|
87
|
+
"free-form validators named after it, and the 'M gated slices' total equals "
|
|
88
|
+
"that count plus one for check_docstring_args_match_signature. Keep the count "
|
|
89
|
+
"words and the named-validator list in step in the same change."
|
|
90
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared fail-safe logger for hook block events.
|
|
2
|
+
|
|
3
|
+
Every blocking hook calls log_hook_block at the moment it decides to block,
|
|
4
|
+
so the user has a single log showing what tripped and why.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_HOOK_BLOCKS_LOG_RELATIVE_PATH = ".claude/logs/hook-blocks.log"
|
|
12
|
+
_MAX_PREVIEW_LENGTH = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_hook_block(
|
|
16
|
+
calling_hook_name: str,
|
|
17
|
+
hook_event: str,
|
|
18
|
+
block_reason: str,
|
|
19
|
+
tool_name: str | None = None,
|
|
20
|
+
offending_input_preview: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Append one JSON record to the hook-blocks log for a block decision.
|
|
23
|
+
|
|
24
|
+
Creates the logs directory if absent. Skips logging when the home directory
|
|
25
|
+
cannot be resolved, and silently swallows all IO errors otherwise, so a
|
|
26
|
+
logging failure never changes a hook's decision.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
calling_hook_name: The script basename of the hook that is blocking.
|
|
30
|
+
hook_event: The hook event type, e.g. ``PreToolUse`` or ``Stop``.
|
|
31
|
+
block_reason: The human-readable reason the hook is blocking.
|
|
32
|
+
tool_name: The Claude tool name when available, e.g. ``Bash``.
|
|
33
|
+
offending_input_preview: A short excerpt of the input that triggered
|
|
34
|
+
the block; truncated to 500 characters before writing.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
home_directory = Path.home()
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
log_path = home_directory / _HOOK_BLOCKS_LOG_RELATIVE_PATH
|
|
43
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
log_record: dict[str, str] = {
|
|
46
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
47
|
+
"hook": calling_hook_name,
|
|
48
|
+
"event": hook_event,
|
|
49
|
+
"reason": block_reason,
|
|
50
|
+
}
|
|
51
|
+
if tool_name is not None:
|
|
52
|
+
log_record["tool"] = tool_name
|
|
53
|
+
if offending_input_preview is not None:
|
|
54
|
+
log_record["preview"] = offending_input_preview[:_MAX_PREVIEW_LENGTH]
|
|
55
|
+
|
|
56
|
+
with log_path.open("a", encoding="utf-8") as log_file:
|
|
57
|
+
log_file.write(json.dumps(log_record) + "\n")
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared helpers that reconstruct the post-edit content of an Edit or MultiEdit.
|
|
2
|
+
|
|
3
|
+
Several PreToolUse blockers judge the content a write would leave on disk rather
|
|
4
|
+
than the raw payload fragment, so an edit on a line the blocker watches still
|
|
5
|
+
participates even when an untouched line elsewhere supplies the context. Both the
|
|
6
|
+
edit-replacement applier and the edit-list extractor are identical across those
|
|
7
|
+
blockers, so they live here once and are imported from each.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"apply_edits",
|
|
14
|
+
"edits_for_tool",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
|
|
19
|
+
"""Return *existing_content* with each Edit/MultiEdit replacement applied in order.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
existing_content: The current on-disk file content.
|
|
23
|
+
all_edits: The Edit payload (as a single-element list) or MultiEdit
|
|
24
|
+
``edits`` list, each a mapping with an ``old_string`` and a
|
|
25
|
+
``new_string``.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The content after replacing the first occurrence of each edit's
|
|
29
|
+
``old_string`` with its ``new_string``, in list order.
|
|
30
|
+
"""
|
|
31
|
+
edited_content = existing_content
|
|
32
|
+
for each_edit in all_edits:
|
|
33
|
+
if not isinstance(each_edit, dict):
|
|
34
|
+
continue
|
|
35
|
+
old_string = each_edit.get("old_string", "")
|
|
36
|
+
new_string = each_edit.get("new_string", "")
|
|
37
|
+
if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
|
|
38
|
+
edited_content = edited_content.replace(old_string, new_string, 1)
|
|
39
|
+
return edited_content
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
|
|
43
|
+
"""Return the edit mappings an Edit or MultiEdit payload carries.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
tool_name: The intercepted tool — ``Edit`` or ``MultiEdit``.
|
|
47
|
+
tool_input: The tool's input payload.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A single-element list holding the Edit payload, or the MultiEdit
|
|
51
|
+
``edits`` list when it is present as a list; an empty list otherwise.
|
|
52
|
+
"""
|
|
53
|
+
if tool_name == "Edit":
|
|
54
|
+
return [tool_input]
|
|
55
|
+
all_edits = tool_input.get("edits", [])
|
|
56
|
+
return all_edits if isinstance(all_edits, list) else []
|
|
@@ -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
|
+
)
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Holds the ordered hosted-hook list with each hook's extra command-line
|
|
4
4
|
arguments and blocking flag, the PostToolUse block-decision string and key,
|
|
5
|
-
and the hook-event name. The dispatcher imports these
|
|
6
|
-
inline in the dispatcher script.
|
|
5
|
+
and the hook-event name. The dispatcher imports each of these by name.
|
|
7
6
|
"""
|
|
8
7
|
|
|
9
8
|
from __future__ import annotations
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Holds the ordered hosted-hook list with per-hook applicable-tool sets, the
|
|
4
4
|
special exit codes, the deny decision string, and the hook-event name. The
|
|
5
|
-
dispatcher imports
|
|
5
|
+
dispatcher imports each of these by name.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -119,6 +119,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
119
119
|
script_relative_path="blocking/claude_md_orphan_file_blocker.py",
|
|
120
120
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
121
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
|
+
),
|
|
122
126
|
HostedHookEntry(
|
|
123
127
|
script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
|
|
124
128
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
@@ -127,6 +131,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
127
131
|
script_relative_path="blocking/open_questions_in_plans_blocker.py",
|
|
128
132
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
129
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
|
+
),
|
|
130
138
|
HostedHookEntry(
|
|
131
139
|
script_relative_path="blocking/plain_language_blocker.py",
|
|
132
140
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
@@ -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"
|
|
@@ -7,7 +7,13 @@ Blocks if hooks use simple 'python3 ~/.claude/...' instead of the exec(open(...)
|
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
|
+
from pathlib import Path
|
|
10
11
|
|
|
12
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
13
|
+
if _hooks_dir not in sys.path:
|
|
14
|
+
sys.path.insert(0, _hooks_dir)
|
|
15
|
+
|
|
16
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
11
17
|
|
|
12
18
|
SIMPLE_PATTERN = re.compile(
|
|
13
19
|
r'python3?\s+~/\.claude/hooks/'
|
|
@@ -56,6 +62,13 @@ def main() -> None:
|
|
|
56
62
|
"permissionDecisionReason": message
|
|
57
63
|
}
|
|
58
64
|
}
|
|
65
|
+
log_hook_block(
|
|
66
|
+
calling_hook_name="hook_format_validator.py",
|
|
67
|
+
hook_event="PreToolUse",
|
|
68
|
+
block_reason=message,
|
|
69
|
+
tool_name=tool_name,
|
|
70
|
+
offending_input_preview=file_path,
|
|
71
|
+
)
|
|
59
72
|
print(json.dumps(result))
|
|
60
73
|
sys.exit(0)
|
|
61
74
|
|