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.
Files changed (105) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +4 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  14. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  15. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  16. package/hooks/blocking/hedging_language_blocker.py +17 -23
  17. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  18. package/hooks/blocking/intent_only_ending_blocker.py +18 -26
  19. package/hooks/blocking/md_to_html_blocker.py +10 -2
  20. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  21. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  22. package/hooks/blocking/plain_language_blocker.py +6 -0
  23. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  24. package/hooks/blocking/pr_description_enforcer.py +6 -0
  25. package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
  26. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  27. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  28. package/hooks/blocking/question_to_user_enforcer.py +19 -23
  29. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  30. package/hooks/blocking/sensitive_file_protector.py +15 -1
  31. package/hooks/blocking/session_handoff_blocker.py +15 -23
  32. package/hooks/blocking/state_description_blocker.py +6 -0
  33. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  34. package/hooks/blocking/tdd_enforcer.py +6 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  36. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  37. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
  38. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  39. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  40. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  41. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  42. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  43. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  44. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  45. package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
  46. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  47. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  48. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  49. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  50. package/hooks/blocking/test_state_description_blocker.py +41 -0
  51. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  52. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  53. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  54. package/hooks/blocking/verified_commit_gate.py +11 -0
  55. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  56. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  57. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  58. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  59. package/hooks/hooks.json +10 -0
  60. package/hooks/hooks_constants/CLAUDE.md +8 -1
  61. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  62. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  63. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  64. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  65. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  66. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  67. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  68. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  69. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
  70. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
  71. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  72. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  73. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  74. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  75. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  76. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  77. package/hooks/hooks_constants/text_stripping.py +36 -0
  78. package/hooks/lifecycle/config_change_guard.py +12 -0
  79. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  80. package/hooks/validation/CLAUDE.md +1 -0
  81. package/hooks/validation/hook_format_validator.py +13 -0
  82. package/hooks/validation/mypy_validator.py +30 -1
  83. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  84. package/hooks/validation/test_hook_format_validator.py +64 -0
  85. package/hooks/validation/test_mypy_validator.py +23 -1
  86. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  87. package/hooks/workflow/auto_formatter.py +8 -5
  88. package/hooks/workflow/test_auto_formatter.py +33 -0
  89. package/package.json +1 -1
  90. package/rules/CLAUDE.md +1 -0
  91. package/rules/docstring-prose-matches-implementation.md +2 -1
  92. package/rules/package-inventory-stale-entry.md +24 -0
  93. package/rules/windows-filesystem-safe.md +2 -0
  94. package/skills/autoconverge/SKILL.md +21 -1
  95. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  96. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
  98. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  99. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  100. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  101. package/skills/autoconverge/workflow/converge.mjs +599 -606
  102. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  103. package/skills/autoconverge/workflow/render_report.py +2 -6
  104. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  105. 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 = os.path.relpath(target_file, project_root)
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(blocking_crash_reason)
209
+ all_block_reasons.append(BLOCKING_CRASH_DENY_REASON)
210
210
  else:
211
211
  non_block_text = each_result.captured_stdout.strip()
212
212
  if non_block_text:
@@ -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(tmp_path: Path) -> None:
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 Python branch breaks out of each loop the moment a command runs, so
83
- the common case spends one fix subprocess plus one format subprocess. This
84
- is a budget for that assumed path, not a guaranteed upper bound: when a
85
- command is missing or times out the loops fall through to the next entry,
86
- so a degraded run can spend more than this budget.
82
+ The fix loop breaks on the first command that runs whether it returns zero
83
+ or non-zero or on a timeout, and continues to the next command only when a
84
+ command is missing (FileNotFoundError). The format loop breaks only on a
85
+ returncode of zero or on a timeout, and continues on a non-zero return or a
86
+ missing command. The common case spends one fix subprocess plus one format
87
+ subprocess. This is a budget for that assumed path, not a guaranteed upper
88
+ bound: when commands are missing or time out the loops can spend more than
89
+ this budget.
87
90
  """
88
91
  fix_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
89
92
  format_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
@@ -105,6 +105,39 @@ class TestRuffFixOnNewFiles:
105
105
  assert completed_hook.returncode == 0
106
106
  assert "import os" in edited_file.read_text(encoding="utf-8")
107
107
 
108
+ def should_leave_tracked_python_file_arriving_through_write_untouched(
109
+ self, git_repository: Path
110
+ ) -> None:
111
+ tracked_file = git_repository / "tracked.py"
112
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
113
+ subprocess.run(
114
+ ["git", "add", "tracked.py"],
115
+ cwd=git_repository,
116
+ capture_output=True,
117
+ check=True,
118
+ )
119
+
120
+ completed_hook = _run_hook("Write", tracked_file)
121
+
122
+ assert completed_hook.returncode == 0
123
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
124
+
125
+
126
+ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> None:
127
+ tracked_file = git_repository / "tracked_module.py"
128
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
129
+ subprocess.run(
130
+ ["git", "add", "tracked_module.py"],
131
+ cwd=git_repository,
132
+ capture_output=True,
133
+ check=True,
134
+ )
135
+
136
+ completed_hook = _run_hook("Write", tracked_file)
137
+
138
+ assert completed_hook.returncode == 0
139
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
140
+
108
141
 
109
142
  def _load_auto_formatter_module() -> object:
110
143
  module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.73.0",
3
+ "version": "1.75.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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. Three 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. 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 four gated slices.
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 ephemeral temp dirs.
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('postCleanAudit returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
44
- const body = functionBody('postCleanAudit');
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, /const auditResult = await postCleanAudit\(head\)/);
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, /const auditResult = await postCleanAudit\(head\)/);
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/);