claude-dev-env 1.73.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.
- package/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -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 +12 -5
- 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/duplicate_rmtree_helper_blocker.py +155 -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 +17 -23
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +18 -26
- 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 +5 -6
- 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 +19 -23
- 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 +15 -23
- 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 +61 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -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 +55 -8
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -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 +8 -1
- 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/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -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 +3 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
- 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/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +23 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -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/rules/windows-filesystem-safe.md +2 -0
- package/skills/autoconverge/SKILL.md +21 -1
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +599 -606
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
|
@@ -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
|
|
@@ -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"
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -35,6 +35,7 @@ if _hooks_directory not in sys.path:
|
|
|
35
35
|
|
|
36
36
|
from mypy_integration import find_pyproject_with_mypy_config # noqa: E402
|
|
37
37
|
|
|
38
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
38
39
|
from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
|
|
39
40
|
CACHE_FILE_ENCODING,
|
|
40
41
|
CONTENT_HASH_CACHE_PASSING_EXIT_CODE,
|
|
@@ -293,6 +294,28 @@ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -
|
|
|
293
294
|
]
|
|
294
295
|
|
|
295
296
|
|
|
297
|
+
def project_relative_path(target_file: str, project_root: str) -> str:
|
|
298
|
+
"""Return *target_file* relative to *project_root*, or its absolute path.
|
|
299
|
+
|
|
300
|
+
On Windows ``os.path.relpath`` raises ``ValueError`` when the two paths sit
|
|
301
|
+
on different mounts (for example a ``Y:`` drive file against a project root
|
|
302
|
+
that resolved to its backing UNC share), so no relative path can span them.
|
|
303
|
+
The absolute target path is then handed to mypy unchanged, which accepts it.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
target_file: The absolute path of the file to type-check.
|
|
307
|
+
project_root: The directory mypy runs from.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
The path relative to *project_root* when one exists, otherwise the
|
|
311
|
+
absolute path of *target_file*.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
return os.path.relpath(target_file, project_root)
|
|
315
|
+
except ValueError:
|
|
316
|
+
return os.path.abspath(target_file)
|
|
317
|
+
|
|
318
|
+
|
|
296
319
|
def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
297
320
|
"""Run mypy on one file from the project root and return its result.
|
|
298
321
|
|
|
@@ -325,7 +348,7 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
|
325
348
|
Returns:
|
|
326
349
|
The mypy exit code paired with its combined stdout and stderr text.
|
|
327
350
|
"""
|
|
328
|
-
relative_file_path =
|
|
351
|
+
relative_file_path = project_relative_path(target_file, project_root)
|
|
329
352
|
mypy_config_file = discover_mypy_config(Path(target_file))
|
|
330
353
|
|
|
331
354
|
content_hash = _composite_content_hash(target_file, mypy_config_file)
|
|
@@ -457,6 +480,12 @@ def main() -> None:
|
|
|
457
480
|
error_summary = format_error_summary(all_error_lines)
|
|
458
481
|
send_block_notification(error_summary)
|
|
459
482
|
block_response = build_block_response(error_summary)
|
|
483
|
+
log_hook_block(
|
|
484
|
+
calling_hook_name="mypy_validator.py",
|
|
485
|
+
hook_event="PostToolUse",
|
|
486
|
+
block_reason=f"[MYPY] Type errors: {error_summary}",
|
|
487
|
+
offending_input_preview=target_file_path,
|
|
488
|
+
)
|
|
460
489
|
print(json.dumps(block_response))
|
|
461
490
|
sys.exit(0)
|
|
462
491
|
|
|
@@ -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(
|
|
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:
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOOK_PATH = Path(__file__).parent / "hook_format_validator.py"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_hook(
|
|
11
|
+
payload: dict[str, object],
|
|
12
|
+
extra_env: dict[str, str] | None = None,
|
|
13
|
+
) -> subprocess.CompletedProcess[str]:
|
|
14
|
+
env = {**os.environ, **(extra_env or {})}
|
|
15
|
+
return subprocess.run(
|
|
16
|
+
[sys.executable, str(HOOK_PATH)],
|
|
17
|
+
input=json.dumps(payload),
|
|
18
|
+
text=True,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
check=False,
|
|
21
|
+
env=env,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_simple_pattern_blocks_with_deny_payload(tmp_path: Path) -> None:
|
|
26
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
27
|
+
payload = {
|
|
28
|
+
"tool_name": "Edit",
|
|
29
|
+
"tool_input": {
|
|
30
|
+
"file_path": str(settings_path),
|
|
31
|
+
"new_string": "python3 ~/.claude/hooks/blocking/my-hook.py",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
hook_run = _run_hook(payload)
|
|
36
|
+
|
|
37
|
+
assert hook_run.returncode == 0
|
|
38
|
+
deny_payload = json.loads(hook_run.stdout)
|
|
39
|
+
hook_specific_output = deny_payload["hookSpecificOutput"]
|
|
40
|
+
assert hook_specific_output["hookEventName"] == "PreToolUse"
|
|
41
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_block_logs_pre_tool_use_event(tmp_path: Path) -> None:
|
|
45
|
+
fake_home = tmp_path / "home"
|
|
46
|
+
fake_home.mkdir()
|
|
47
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
48
|
+
payload = {
|
|
49
|
+
"tool_name": "Edit",
|
|
50
|
+
"tool_input": {
|
|
51
|
+
"file_path": str(settings_path),
|
|
52
|
+
"new_string": "python3 ~/.claude/hooks/blocking/my-hook.py",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
hook_run = _run_hook(
|
|
57
|
+
payload,
|
|
58
|
+
extra_env={"HOME": str(fake_home), "USERPROFILE": str(fake_home)},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert hook_run.returncode == 0
|
|
62
|
+
log_path = fake_home / ".claude" / "logs" / "hook-blocks.log"
|
|
63
|
+
logged_record = json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1])
|
|
64
|
+
assert logged_record["event"] == "PreToolUse"
|
|
@@ -15,6 +15,8 @@ the cache directory redirected to a temporary directory.
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
18
20
|
from pathlib import Path
|
|
19
21
|
from types import ModuleType
|
|
20
22
|
|
|
@@ -92,7 +94,7 @@ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) ->
|
|
|
92
94
|
assert command[-1] == "package/module.py"
|
|
93
95
|
|
|
94
96
|
|
|
95
|
-
def test_build_mypy_command_omits_config_file_when_absent(
|
|
97
|
+
def test_build_mypy_command_omits_config_file_when_absent() -> None:
|
|
96
98
|
validator = _load_validator()
|
|
97
99
|
|
|
98
100
|
command = validator.build_mypy_command("package/module.py", None)
|
|
@@ -275,3 +277,23 @@ def test_content_hash_skip_invalidated_when_mypy_config_tightens(
|
|
|
275
277
|
)
|
|
276
278
|
assert tightened_exit_code != 0
|
|
277
279
|
assert ": error:" in tightened_output
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_project_relative_path_within_root_returns_relative() -> None:
|
|
283
|
+
validator = _load_validator()
|
|
284
|
+
project_root = os.path.join("base", "project")
|
|
285
|
+
target_file = os.path.join(project_root, "package", "module.py")
|
|
286
|
+
assert validator.project_relative_path(target_file, project_root) == os.path.join(
|
|
287
|
+
"package", "module.py"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_project_relative_path_across_mounts_falls_back_to_absolute() -> None:
|
|
292
|
+
if sys.platform != "win32":
|
|
293
|
+
pytest.skip("cross-mount relpath ValueError only occurs on Windows")
|
|
294
|
+
validator = _load_validator()
|
|
295
|
+
target_file = "Y:\\repository\\package\\module.py"
|
|
296
|
+
project_root = "C:\\other\\root"
|
|
297
|
+
assert validator.project_relative_path(target_file, project_root) == os.path.abspath(
|
|
298
|
+
target_file
|
|
299
|
+
)
|
|
@@ -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
|
|
83
|
-
|
|
84
|
-
is
|
|
85
|
-
|
|
86
|
-
|
|
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
package/rules/CLAUDE.md
CHANGED
|
@@ -28,6 +28,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
28
28
|
| `no-historical-clutter.md` | Documentation describes current state only; no historical or transitional language |
|
|
29
29
|
| `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
|
|
30
30
|
| `orphan-css-class.md` | Every `class="..."` attribute in Python-generated markup has a matching selector in the `<style>` block |
|
|
31
|
+
| `package-inventory-stale-entry.md` | A new production code file added to a directory carries an entry in that directory's `README.md`/`CLAUDE.md` file inventory |
|
|
31
32
|
| `parallel-tools.md` | Make all independent tool calls in a single response |
|
|
32
33
|
| `plain-language.md` | Everyday words, short active sentences, lead with the answer |
|
|
33
34
|
| `prompt-workflow-context-controls.md` | Keep prompt-workflow instruction layers small and stable; load heavy skills on demand |
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
|
|
8
8
|
|
|
9
|
-
The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names.
|
|
9
|
+
The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Four more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. `check_docstring_returns_plural_cardinality` covers a `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) while the returned dict literal holds exactly one key in that family (`sheen_mid`) — the drift where a single-key family carries a plural noun, so the prose claims a cardinality of two or more that the dict does not hold. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the five gated slices.
|
|
10
10
|
|
|
11
11
|
## What to check before you write the docstring
|
|
12
12
|
|
|
@@ -16,6 +16,7 @@ Read the body and the docstring side by side:
|
|
|
16
16
|
- **Suppressor / skip lists.** A body with several early returns that suppress the check names each suppressor in the prose.
|
|
17
17
|
- **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
|
|
18
18
|
- **Step order.** A docstring that says `A then B then C` matches the call order in the body. A step enumeration that names the body's linear steps also names every corrective step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`). The `check_docstring_step_enumeration_dispatch_coverage` gate blocks the branch-guarded-dispatch form of this drift — a step-enumeration docstring that omits a two-or-more-token dispatch step the body guards inside a branch — at Write/Edit time.
|
|
19
|
+
- **Returns-clause cardinality.** A `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) matches the count of keys in that family in the returned dict literal. When the dict holds one key in the family (`sheen_mid`), the noun is singular (`the sheen stop`); a plural noun there claims two or more entries the dict does not hold. The `check_docstring_returns_plural_cardinality` gate blocks the single-key-with-plural-noun form of this drift at Write/Edit time.
|
|
19
20
|
- **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
|
|
20
21
|
- **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
|
|
21
22
|
- **Companion-doc ordering and content claims.** A `SKILL.md` (or sibling `.md`) sentence that names a produced artifact and claims its order (`sorted`, `alphabetical`, `in sorted order`) or its content (`the at-risk names`, `just the current set`) matches the producer function's docstring and body for that same artifact. A producer that builds the artifact by merging stored names with new names and appending — preserving file order, not re-sorting the union — leaves a doc that still says `sorted` drifted on both counts: the order claim is wrong, and the content claim hides the merged-in prior entries. When the producer's ordering or union changes, the same change updates the companion doc. The two move together in one commit, even when the producer edit does not touch the `.md` file.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# New Production File Absent From Its Package Inventory
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any Write that creates a new production code file (`.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`) in a directory whose sibling `README.md` or `CLAUDE.md` already names two or more of the directory's files in backticks.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
A package directory that documents its own files in a `README.md` Layout table or a `CLAUDE.md` "Key files" list keeps that inventory in step with the directory. A new production file the inventory does not name leaves the inventory and the directory disagreeing on the package's file set: a reader who trusts the inventory to map the directory misses the new file.
|
|
8
|
+
|
|
9
|
+
When you create a new production file in such a directory, add an entry naming it — a row in the `README.md` table, a bullet in the `CLAUDE.md` list — in the same change. The entry names the file in backticks and says what it does.
|
|
10
|
+
|
|
11
|
+
## What the gate checks
|
|
12
|
+
|
|
13
|
+
The `package_inventory_stale_blocker.py` hook runs on every Write whose target is a new file (a path not yet on disk). It:
|
|
14
|
+
|
|
15
|
+
1. Skips a target that is not a production code file (`.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`), an exempt basename (`__init__.py`, `conftest.py`, `setup.py`, `_path_setup.py`), a test file (`test_*.py`, `*_test.py`, `*.spec.*`, `*.test.*`), or a file directly inside a `config/` or `tests/` directory.
|
|
16
|
+
2. Reads each `README.md` and `CLAUDE.md` present in the target's own directory and collects every bare filename they name in backticks. A backticked token holding a path contributes its final segment, so `pipeline/seam_continuity.py` in an inventory counts as naming `seam_continuity.py`. A multi-word command-example span — one carrying whitespace or shell punctuation (`:`, `$`, `<`, `>`), such as `parent:node_modules package.json` or `python <file>.py` — names no literal file and is dropped.
|
|
17
|
+
3. Filters the named basenames to those that exist as a file in the target's own directory — the inventory's own sibling files — and treats the directory as carrying a maintained inventory only when two or more such sibling files are named. A directory with no inventory, one whose `README.md` mentions a single file in passing, or one whose inventory prose names only files living in other directories (so no named basename is an on-disk sibling) is out of scope.
|
|
18
|
+
4. Blocks the write when the new file's basename appears in no present inventory. An unreadable or oversized inventory document is skipped, so a missing inventory never blocks a write.
|
|
19
|
+
|
|
20
|
+
The check fires on Write only — editing an existing file adds no new inventory entry — and stays quiet for a directory with no inventory document, an inventory naming too few siblings to be a maintained list, an exempt or test file, and a file the inventory already names.
|
|
21
|
+
|
|
22
|
+
## Why this is a hook, not a lint pass
|
|
23
|
+
|
|
24
|
+
A package inventory that omits a file reads as a complete map of the directory while leaving one file off it. A reader trusting the inventory to list the package misses the new file, and the gap survives review because the inventory still looks complete. Catching it as the new file is written keeps the inventory and the directory in step. This is the counterpart to `claude-md-orphan-file.md`, which catches the reverse drift: an inventory entry naming a file the directory does not hold.
|
|
@@ -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.
|
|
@@ -261,7 +261,27 @@ agents never inline a destructive-command literal (`rm -rf`, `git reset --hard`,
|
|
|
261
261
|
`dd`) into a Bash command — the `destructive_command_blocker` hook matches those
|
|
262
262
|
patterns as raw text, and a confirmation prompt no human can answer would stall
|
|
263
263
|
the run. Agents verify destructive-blocker behavior through the committed test
|
|
264
|
-
suite (`python -m pytest`) and keep scratch work in
|
|
264
|
+
suite (`python -m pytest`) and keep scratch work in the OS temp dir. The preamble
|
|
265
|
+
describes the narrowest rm auto-allow path — a standalone Bash call whose target
|
|
266
|
+
resolves inside the ephemeral namespace (`/tmp`, `/temp`, the OS temp root, or the
|
|
267
|
+
run worktree) — and a compound path that accepts an rm joined with benign
|
|
268
|
+
reporting segments when every rm target is an absolute ephemeral path. Both of
|
|
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
|
|
275
|
+
ephemeral working directory (it `cd`s into one, or runs under one): that
|
|
276
|
+
cwd-scoped path resolves the target against the declared cwd, fails closed on
|
|
277
|
+
`$(...)`, backticks, and unknown variables, and resolves the known temporary
|
|
278
|
+
variables `TEMP`, `TMP`, `TMPDIR`, and `CLAUDE_JOB_DIR` to the OS temp root, so
|
|
279
|
+
under that declared ephemeral cwd a bare `$CLAUDE_JOB_DIR/tmp/<name>` target and a
|
|
280
|
+
relative target after a `cd` are auto-allowed. Even so, for any cleanup whose path
|
|
281
|
+
is variable-built or whose teardown spans multiple steps, agents author a Python
|
|
282
|
+
helper file and run it as `python <file>.py` — keeping every destructive literal
|
|
283
|
+
out of a Bash command string entirely and independent of which auto-allow path
|
|
284
|
+
matches.
|
|
265
285
|
|
|
266
286
|
- **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
|
|
267
287
|
the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
|
|
@@ -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
|
|
|
@@ -40,8 +40,9 @@ test('cleanAuditBlocker falls back to a no-result reason when the post agent die
|
|
|
40
40
|
assert.match(message, /the post agent returned no result/);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test('
|
|
44
|
-
const body = functionBody('
|
|
43
|
+
test('the post-clean-audit task in resumeGeneralUtilityAgent returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
|
|
44
|
+
const body = functionBody('resumeGeneralUtilityAgent');
|
|
45
|
+
assert.match(body, /task === 'post-clean-audit'/);
|
|
45
46
|
assert.match(body, /schema: CLEAN_AUDIT_SCHEMA/);
|
|
46
47
|
assert.doesNotMatch(body, /agent transcript \(unused\)/);
|
|
47
48
|
});
|
|
@@ -58,7 +59,7 @@ test('the standards-only call site breaks with a clean-audit blocker when the po
|
|
|
58
59
|
convergeSource.indexOf('if (isStandardsOnlyRound(findings)) {'),
|
|
59
60
|
convergeSource.indexOf('if (findings.length > 0) {'),
|
|
60
61
|
);
|
|
61
|
-
assert.match(branch, /
|
|
62
|
+
assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
|
|
62
63
|
assert.match(branch, /if \(!auditResult\?\.posted\)/);
|
|
63
64
|
assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
|
|
64
65
|
assert.match(branch, /\bbreak\b/);
|
|
@@ -69,7 +70,7 @@ test('the all-clean call site breaks with a clean-audit blocker when the post do
|
|
|
69
70
|
convergeSource.indexOf('all lenses clean on'),
|
|
70
71
|
convergeSource.indexOf("if (phase === 'COPILOT') {"),
|
|
71
72
|
);
|
|
72
|
-
assert.match(branch, /
|
|
73
|
+
assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
|
|
73
74
|
assert.match(branch, /if \(!auditResult\?\.posted\)/);
|
|
74
75
|
assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
|
|
75
76
|
assert.match(branch, /\bbreak\b/);
|