claude-dev-env 1.74.0 → 1.76.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 (78) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/docs/CODE_RULES.md +1 -1
  6. package/hooks/blocking/CLAUDE.md +3 -2
  7. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  8. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  9. package/hooks/blocking/code_rules_enforcer.py +36 -5
  10. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  11. package/hooks/blocking/code_rules_shared.py +8 -5
  12. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
  14. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  15. package/hooks/blocking/hedging_language_blocker.py +1 -13
  16. package/hooks/blocking/intent_only_ending_blocker.py +1 -15
  17. package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
  18. package/hooks/blocking/question_to_user_enforcer.py +1 -11
  19. package/hooks/blocking/session_handoff_blocker.py +1 -15
  20. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  22. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  23. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  24. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  25. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  26. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  27. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
  28. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  29. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  30. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  31. package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
  32. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  33. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  34. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  35. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  36. package/hooks/blocking/verification_verdict_store.py +19 -5
  37. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  38. package/hooks/hooks_constants/CLAUDE.md +4 -1
  39. package/hooks/hooks_constants/blocking_check_limits.py +30 -1
  40. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -1
  42. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  43. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  44. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  45. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
  46. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
  47. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  48. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  49. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  50. package/hooks/hooks_constants/text_stripping.py +36 -0
  51. package/hooks/validation/CLAUDE.md +1 -0
  52. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  53. package/hooks/validation/test_mypy_validator.py +1 -1
  54. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  55. package/hooks/workflow/auto_formatter.py +8 -5
  56. package/hooks/workflow/test_auto_formatter.py +33 -0
  57. package/package.json +1 -1
  58. package/rules/claude-md-orphan-file.md +7 -8
  59. package/rules/docstring-prose-matches-implementation.md +1 -0
  60. package/rules/package-inventory-stale-entry.md +8 -0
  61. package/rules/windows-filesystem-safe.md +2 -0
  62. package/skills/anthropic-plan/CLAUDE.md +1 -1
  63. package/skills/anthropic-plan/SKILL.md +15 -2
  64. package/skills/autoconverge/SKILL.md +6 -3
  65. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  66. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  67. package/skills/autoconverge/workflow/converge.contract.test.mjs +306 -137
  68. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  69. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -44
  70. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  71. package/skills/autoconverge/workflow/converge.mjs +581 -613
  72. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  73. package/skills/autoconverge/workflow/render_report.py +2 -6
  74. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  75. package/skills/autoconverge/workflow/test_render_report.py +1 -0
  76. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  77. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  78. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -15,6 +15,7 @@ __all__ = [
15
15
  "HOOK_EVENT_NAME",
16
16
  "BLOCKING_CRASH_EXIT_CODE",
17
17
  "EXIT_CODE_TWO_DENY_REASON",
18
+ "BLOCKING_CRASH_DENY_REASON",
18
19
  "WRITE_TOOL_NAME",
19
20
  "EDIT_TOOL_NAME",
20
21
  "MULTI_EDIT_TOOL_NAME",
@@ -31,6 +32,7 @@ ALLOW_DECISION = "allow"
31
32
  HOOK_EVENT_NAME = "PreToolUse"
32
33
  BLOCKING_CRASH_EXIT_CODE = 2
33
34
  EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
35
+ BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
34
36
 
35
37
  WRITE_TOOL_NAME = "Write"
36
38
  EDIT_TOOL_NAME = "Edit"
@@ -59,8 +61,8 @@ class HostedHookEntry:
59
61
  native_module_name: The importable module name whose evaluate function
60
62
  the dispatcher calls in-process for this hook, or None when the hook
61
63
  runs via runpy under __main__. The named module exposes a function
62
- named NATIVE_EVALUATE_FUNCTION_NAME taking the payload dict and
63
- returning a deny-reason string or None.
64
+ named `evaluate` taking the payload dict and returning a deny-reason
65
+ string or None.
64
66
  """
65
67
 
66
68
  script_relative_path: str
@@ -94,6 +96,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
94
96
  script_relative_path="blocking/windows_rmtree_blocker.py",
95
97
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
96
98
  ),
99
+ HostedHookEntry(
100
+ script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
101
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
102
+ ),
97
103
  HostedHookEntry(
98
104
  script_relative_path="blocking/state_description_blocker.py",
99
105
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
@@ -0,0 +1,43 @@
1
+ """Tests for the PostToolUse dispatcher constants module."""
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ _VALIDATION_DIR = _HOOKS_ROOT / "validation"
11
+ if str(_VALIDATION_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_VALIDATION_DIR))
13
+
14
+ from post_tool_use_dispatcher import (
15
+ PostHostedHookResult,
16
+ aggregate_post_hosted_hook_results,
17
+ )
18
+
19
+ from hooks_constants.post_tool_use_dispatcher_constants import (
20
+ BLOCKING_CRASH_DENY_REASON,
21
+ )
22
+
23
+
24
+ def test_blocking_hook_crash_block_reason_surfaces_the_constant() -> None:
25
+ crash_result = PostHostedHookResult(
26
+ captured_stdout="",
27
+ did_crash=True,
28
+ is_blocking=True,
29
+ )
30
+ decision = aggregate_post_hosted_hook_results([crash_result])
31
+ assert decision.should_block
32
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_block_reasons
33
+
34
+
35
+ def test_non_blocking_hook_crash_does_not_surface_the_constant() -> None:
36
+ crash_result = PostHostedHookResult(
37
+ captured_stdout="",
38
+ did_crash=True,
39
+ is_blocking=False,
40
+ )
41
+ decision = aggregate_post_hosted_hook_results([crash_result])
42
+ assert not decision.should_block
43
+ assert BLOCKING_CRASH_DENY_REASON not in decision.all_block_reasons
@@ -0,0 +1,99 @@
1
+ """Tests for the PreToolUse dispatcher hosted-hook roster."""
2
+
3
+ import importlib
4
+ import pathlib
5
+ import sys
6
+
7
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
8
+ if str(_HOOKS_ROOT) not in sys.path:
9
+ sys.path.insert(0, str(_HOOKS_ROOT))
10
+
11
+ _BLOCKING_DIR = _HOOKS_ROOT / "blocking"
12
+ if str(_BLOCKING_DIR) not in sys.path:
13
+ sys.path.insert(0, str(_BLOCKING_DIR))
14
+
15
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
16
+ ALL_HOSTED_HOOK_ENTRIES,
17
+ ALL_WRITE_AND_EDIT_TOOL_NAMES,
18
+ BLOCKING_CRASH_DENY_REASON,
19
+ EDIT_TOOL_NAME,
20
+ WRITE_TOOL_NAME,
21
+ )
22
+ from pre_tool_use_dispatcher import (
23
+ HostedHookResult,
24
+ aggregate_hosted_hook_results,
25
+ )
26
+
27
+
28
+ def _entry_for(script_relative_path: str):
29
+ matching_entries = [
30
+ each_entry
31
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
32
+ if each_entry.script_relative_path == script_relative_path
33
+ ]
34
+ return matching_entries[0] if matching_entries else None
35
+
36
+
37
+ def test_roster_includes_duplicate_rmtree_helper_blocker_script_path() -> None:
38
+ all_registered_script_paths = [
39
+ each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
40
+ ]
41
+ assert "blocking/duplicate_rmtree_helper_blocker.py" in all_registered_script_paths, (
42
+ "duplicate_rmtree_helper_blocker must be hosted by the dispatcher so a local "
43
+ "re-definition of the Windows-safe rmtree helper trio is blocked at Write time"
44
+ )
45
+
46
+
47
+ def test_duplicate_rmtree_helper_blocker_applies_to_write_and_edit() -> None:
48
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
49
+ assert entry is not None
50
+ assert WRITE_TOOL_NAME in entry.applicable_tool_names
51
+ assert EDIT_TOOL_NAME in entry.applicable_tool_names
52
+
53
+
54
+ def test_duplicate_rmtree_helper_blocker_is_blocking() -> None:
55
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
56
+ assert entry is not None
57
+ assert entry.is_blocking is True
58
+
59
+
60
+ def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
61
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
62
+ assert entry is not None
63
+ assert entry.native_module_name is None
64
+
65
+
66
+ def test_windows_rmtree_blocker_still_registered() -> None:
67
+ entry = _entry_for("blocking/windows_rmtree_blocker.py")
68
+ assert entry is not None
69
+ assert entry.applicable_tool_names == ALL_WRITE_AND_EDIT_TOOL_NAMES
70
+
71
+
72
+ def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
73
+ crash_result = HostedHookResult(
74
+ exit_code=0,
75
+ captured_stdout="",
76
+ did_crash=True,
77
+ is_blocking=True,
78
+ )
79
+ decision = aggregate_hosted_hook_results([crash_result])
80
+ assert decision.should_deny
81
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
82
+
83
+
84
+ def test_every_native_module_exposes_a_callable_evaluate() -> None:
85
+ nativized_entries = [
86
+ each_entry
87
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
88
+ if each_entry.native_module_name is not None
89
+ ]
90
+ assert nativized_entries, (
91
+ "the roster must carry at least one nativized hook for this test to lock the contract"
92
+ )
93
+ for each_entry in nativized_entries:
94
+ native_module = importlib.import_module(each_entry.native_module_name)
95
+ evaluate_function = getattr(native_module, "evaluate", None)
96
+ assert callable(evaluate_function), (
97
+ f"{each_entry.native_module_name} must expose a callable named evaluate, "
98
+ "matching the native_module_name docstring contract"
99
+ )
@@ -0,0 +1,39 @@
1
+ """Tests for the shared strip_code_and_quotes helper."""
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ from hooks_constants.text_stripping import strip_code_and_quotes
11
+
12
+
13
+ def test_removes_fenced_code_block() -> None:
14
+ text = "before\n```python\nshould I run this?\n```\nafter"
15
+ stripped = strip_code_and_quotes(text)
16
+ assert "should I run this?" not in stripped
17
+ assert "before" in stripped
18
+ assert "after" in stripped
19
+
20
+
21
+ def test_removes_inline_code_span() -> None:
22
+ text = "the function `would you like` is named oddly"
23
+ stripped = strip_code_and_quotes(text)
24
+ assert "would you like" not in stripped
25
+ assert "the function" in stripped
26
+ assert "is named oddly" in stripped
27
+
28
+
29
+ def test_removes_leading_blockquote_lines() -> None:
30
+ text = "real line\n> should I proceed?\nfinal line"
31
+ stripped = strip_code_and_quotes(text)
32
+ assert "should I proceed?" not in stripped
33
+ assert "real line" in stripped
34
+ assert "final line" in stripped
35
+
36
+
37
+ def test_leaves_plain_prose_unchanged() -> None:
38
+ text = "This sentence carries no code or quotes."
39
+ assert strip_code_and_quotes(text) == text
@@ -0,0 +1,36 @@
1
+ """Shared text-stripping helper for the Stop-hook prose blockers.
2
+
3
+ Several Stop hooks judge the prose of an assistant message and must ignore
4
+ fenced code blocks, inline code spans, and leading blockquotes so a phrase that
5
+ appears only inside code or a quote never trips the detector. The stripping
6
+ logic is identical across those blockers, so it lives here once and is imported
7
+ from each.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ __all__ = [
15
+ "strip_code_and_quotes",
16
+ ]
17
+
18
+ CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
19
+ INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
20
+ QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
21
+
22
+
23
+ def strip_code_and_quotes(text: str) -> str:
24
+ """Remove fenced code blocks, inline code, and blockquotes from prose.
25
+
26
+ Args:
27
+ text: The raw assistant message to clean of code and quoted lines.
28
+
29
+ Returns:
30
+ The text with every fenced code block, inline code span, and leading
31
+ blockquote line removed, so only the prose a reader sees remains.
32
+ """
33
+ text = CODE_BLOCK_PATTERN.sub("", text)
34
+ text = INLINE_CODE_PATTERN.sub("", text)
35
+ text = QUOTED_BLOCK_PATTERN.sub("", text)
36
+ return text
@@ -9,6 +9,7 @@ PostToolUse hooks that validate code quality after Claude writes or edits a file
9
9
  | `mypy_validator.py` | PostToolUse (Write/Edit on `.py` files) | Runs mypy on the written file and blocks (via PostToolUse block decision) when type errors are found — catches missing attributes, wrong signatures, type mismatches, and import errors |
10
10
  | `hook_format_validator.py` | PostToolUse | Validates that a hook script's output JSON matches the expected Claude Code hook-output schema |
11
11
  | `test_mypy_validator.py` | — | Tests for `mypy_validator.py` |
12
+ | `test_hook_format_validator.py` | — | Tests for `hook_format_validator.py` |
12
13
 
13
14
  ## Conventions
14
15
 
@@ -33,6 +33,7 @@ if _hooks_directory not in sys.path:
33
33
  from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402
34
34
  ALL_POST_HOSTED_HOOK_ENTRIES,
35
35
  BLOCK_DECISION,
36
+ BLOCKING_CRASH_DENY_REASON,
36
37
  DECISION_KEY,
37
38
  EMPTY_REASON_BLOCK_FALLBACK,
38
39
  HOOK_EVENT_NAME,
@@ -197,7 +198,6 @@ def aggregate_post_hosted_hook_results(
197
198
  A PostDispatcherDecision with the aggregated allow-or-block signal,
198
199
  all block reasons, and all non-block stdout.
199
200
  """
200
- blocking_crash_reason = "[dispatcher] hook crash in blocking hook — write blocked for safety"
201
201
  all_block_reasons: list[str] = []
202
202
  all_non_block_stdout: list[str] = []
203
203
 
@@ -206,7 +206,7 @@ def aggregate_post_hosted_hook_results(
206
206
  if is_block:
207
207
  all_block_reasons.append(block_reason if block_reason else EMPTY_REASON_BLOCK_FALLBACK)
208
208
  elif each_result.did_crash and each_result.is_blocking:
209
- all_block_reasons.append(blocking_crash_reason)
209
+ all_block_reasons.append(BLOCKING_CRASH_DENY_REASON)
210
210
  else:
211
211
  non_block_text = each_result.captured_stdout.strip()
212
212
  if non_block_text:
@@ -94,7 +94,7 @@ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) ->
94
94
  assert command[-1] == "package/module.py"
95
95
 
96
96
 
97
- def test_build_mypy_command_omits_config_file_when_absent(tmp_path: Path) -> None:
97
+ def test_build_mypy_command_omits_config_file_when_absent() -> None:
98
98
  validator = _load_validator()
99
99
 
100
100
  command = validator.build_mypy_command("package/module.py", None)
@@ -39,6 +39,7 @@ from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402, I00
39
39
  from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402, I001
40
40
  ALL_POST_HOSTED_HOOK_ENTRIES,
41
41
  BLOCK_DECISION,
42
+ BLOCKING_CRASH_DENY_REASON,
42
43
  EMPTY_REASON_BLOCK_FALLBACK,
43
44
  PLUGIN_ROOT_PLACEHOLDER,
44
45
  PostHostedHookEntry,
@@ -608,3 +609,8 @@ def test_blocking_hook_crash_surfaces_a_block() -> None:
608
609
  "The block reason from a blocking hook crash must reference the dispatcher.\n"
609
610
  f"Got: {aggregated_decision.all_block_reasons[0]!r}"
610
611
  )
612
+ assert BLOCKING_CRASH_DENY_REASON in aggregated_decision.all_block_reasons, (
613
+ "The block reason from a blocking hook crash must be the "
614
+ "BLOCKING_CRASH_DENY_REASON constant.\n"
615
+ f"Got: {aggregated_decision.all_block_reasons!r}"
616
+ )
@@ -79,11 +79,14 @@ def has_prettier_config(file_path: str) -> bool:
79
79
  def budgeted_python_format_seconds() -> int:
80
80
  """Return the wall-clock budget for the two-subprocess happy path.
81
81
 
82
- The Python branch breaks out of each loop the moment a command runs, so
83
- the common case spends one fix subprocess plus one format subprocess. This
84
- is a budget for that assumed path, not a guaranteed upper bound: when a
85
- command is missing or times out the loops fall through to the next entry,
86
- so a degraded run can spend more than this budget.
82
+ The fix loop breaks on the first command that runs whether it returns zero
83
+ or non-zero or on a timeout, and continues to the next command only when a
84
+ command is missing (FileNotFoundError). The format loop breaks only on a
85
+ returncode of zero or on a timeout, and continues on a non-zero return or a
86
+ missing command. The common case spends one fix subprocess plus one format
87
+ subprocess. This is a budget for that assumed path, not a guaranteed upper
88
+ bound: when commands are missing or time out the loops can spend more than
89
+ this budget.
87
90
  """
88
91
  fix_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
89
92
  format_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
@@ -105,6 +105,39 @@ class TestRuffFixOnNewFiles:
105
105
  assert completed_hook.returncode == 0
106
106
  assert "import os" in edited_file.read_text(encoding="utf-8")
107
107
 
108
+ def should_leave_tracked_python_file_arriving_through_write_untouched(
109
+ self, git_repository: Path
110
+ ) -> None:
111
+ tracked_file = git_repository / "tracked.py"
112
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
113
+ subprocess.run(
114
+ ["git", "add", "tracked.py"],
115
+ cwd=git_repository,
116
+ capture_output=True,
117
+ check=True,
118
+ )
119
+
120
+ completed_hook = _run_hook("Write", tracked_file)
121
+
122
+ assert completed_hook.returncode == 0
123
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
124
+
125
+
126
+ def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> None:
127
+ tracked_file = git_repository / "tracked_module.py"
128
+ tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
129
+ subprocess.run(
130
+ ["git", "add", "tracked_module.py"],
131
+ cwd=git_repository,
132
+ capture_output=True,
133
+ check=True,
134
+ )
135
+
136
+ completed_hook = _run_hook("Write", tracked_file)
137
+
138
+ assert completed_hook.returncode == 0
139
+ assert "import os" in tracked_file.read_text(encoding="utf-8")
140
+
108
141
 
109
142
  def _load_auto_formatter_module() -> object:
110
143
  module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.74.0",
3
+ "version": "1.76.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,24 +1,23 @@
1
1
  # Orphan File Reference in a Per-Directory CLAUDE.md
2
2
 
3
- **When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks.
3
+ **When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks, or that shows run commands invoking those files inside fenced code blocks.
4
4
 
5
5
  ## Rule
6
6
 
7
- Every bare filename a per-directory `CLAUDE.md` table names in its first column points at a file that exists in the directory subtree the `CLAUDE.md` describes. A first-column cell naming a file that exists nowhere in that subtree points a reader at something that is not there: the listing claims a file the directory does not hold.
7
+ Every bare filename a per-directory `CLAUDE.md` names points at a file that exists in the directory subtree the `CLAUDE.md` describes — both the filenames its table cells list and the scripts its fenced run commands invoke (`python script.py`). A table cell or a run command naming a file that exists nowhere in that subtree points a reader at something that is not there: the doc claims a file the directory does not hold.
8
8
 
9
- When you add a table row, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row that named it.
9
+ When you add a table row or a run command, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row and the run command that named it.
10
10
 
11
11
  ## What the gate checks
12
12
 
13
13
  The `claude_md_orphan_file_blocker.py` hook runs on every Write, Edit, and MultiEdit whose target basename is `CLAUDE.md`. It:
14
14
 
15
15
  1. Reads the content the tool would leave on disk. For a Write that is the full `content`. For an Edit or MultiEdit it reconstructs the post-edit file — the existing on-disk file with the replacements applied — and also notes which orphans the file already held before the edit, so a pre-existing orphan on an untouched line is excluded and only an orphan the edit introduces is reported; when the existing file cannot be read, it scans the raw `new_string` fragment(s) instead.
16
- 2. Skips any line inside a fenced code block (between a ``` or `~~~` fence pair), since an example table there is documentation, not a live listing.
17
- 3. Takes the first column of each remaining markdown table row and keeps the cells that name a bare filename: wrapped in backticks, no path separator, not a slash-command, and ending in a known file extension (`.py`, `.md`, `.json`, `.mjs`, `.js`, `.ts`, `.ps1`, `.cmd`, `.ahk`, `.yml`, `.yaml`, `.sh`, `.txt`, `.cfg`, `.toml`, `.ini`).
18
- 4. Blocks the write when a named file exists nowhere under the scan root — the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
16
+ 2. Collects two kinds of referenced filename. Table cells: the first column of each markdown table row **outside** a fenced code block, keeping cells that name a bare filename wrapped in backticks, no path separator, not a slash-command, ending in a known file extension (`.py`, `.md`, `.json`, `.mjs`, `.js`, `.ts`, `.ps1`, `.cmd`, `.ahk`, `.yml`, `.yaml`, `.sh`, `.txt`, `.cfg`, `.toml`, `.ini`). Run commands: each line **inside** a fenced code block (between a ``` or `~~~` fence pair) that invokes an interpreter (`python`, `python.exe`, `python3`, `node`, `pwsh`, `powershell`, `bash`, `sh`, `ruby`, `perl`) on a script, taking that script's basename when it ends in `.py`, `.mjs`, `.js`, `.ts`, `.ps1`, `.sh`, `.rb`, or `.pl`. A fenced *table row* is an example, not a live listing, so it contributes no table-cell filename; a fenced *run command* is the contract a reader runs, so its script filename is checked.
17
+ 3. Blocks the write when a referenced filename from a table cell or a fenced run command exists nowhere under the scan root the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
19
18
 
20
- The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
19
+ The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, for an inline `python x.py` mention outside a fence (prose, not a runnable contract), and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
21
20
 
22
21
  ## Why this is a hook, not a lint pass
23
22
 
24
- A table row that names an absent file reads as a contract: a reader trusts the listing to map the directory. A wrong row sends the reader looking for a file that is not there and erodes trust in every other row. Catching it as each row is written keeps the table and the directory in step.
23
+ A table row or a run command that names an absent file reads as a contract: a reader trusts the listing to map the directory and trusts the shown command to run. A wrong row sends the reader looking for a file that is not there; a stale run command fails the moment the reader runs it. Both erode trust in every other entry. Catching them as each line is written keeps the doc and the directory in step.
@@ -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
+ - **JS/`.mjs` resume-task enumerations.** A `spawn<Role>Agent` JSDoc that enumerates its sibling `resume<Role>Agent`'s resume tasks in a parenthetical `resume (repair-verify, hardening-verify)` list names every `task === '<name>'` branch the resume body dispatches on. The `check_js_resume_task_enumeration_coverage` gate blocks the JavaScript form of this drift — a spawn JSDoc whose resume enumeration omits a dispatched task — at Write/Edit time. This is the `.mjs` slice of the same Category O6 standard the Python gates carry; the Python AST docstring gates never inspect JavaScript source.
19
20
  - **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.
20
21
  - **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.
21
22
  - **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.
@@ -8,6 +8,14 @@ A package directory that documents its own files in a `README.md` Layout table o
8
8
 
9
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
10
 
11
+ ## Companion: keep the Purpose/scope sentence in step with the new responsibility
12
+
13
+ The file-list entry is the deterministic slice the gate enforces. A package inventory also carries a free-prose scope sentence — a `## Purpose` paragraph in a `CLAUDE.md`, a one-line summary the parent directory's inventory gives each subdirectory — that names the responsibilities the package's modules cover. When the new module adds a responsibility the scope sentence omits, the same change broadens that sentence to name it, and updates the parent inventory's one-line summary of this subdirectory to match.
14
+
15
+ Take a `files/` package whose `Purpose` reads "Holds helpers for downloading files over HTTP and extracting zip archives" and whose parent summary reads "file download, extraction, and path config helpers". Once a `force_remove.py` module that removes a directory tree sits beside the download helpers, both sentences name a narrower responsibility set than the directory holds. The required file-list bullet alone leaves that gap open. Broaden the `Purpose` sentence to name directory removal, and broaden the parent summary to match, in the same change that adds the module and its bullet.
16
+
17
+ This scope-sentence slice is free prose: a hook cannot derive a module's responsibility from its filename, so the gate leaves it to judgment. It is the judgment companion to the file-list entry the gate enforces, and it belongs in the same change. This is the `category-o-docstring-vs-impl-drift` (O8) orphaned-doc-claim shape applied to a package inventory: a behavior change orphans a scope claim the prose still makes.
18
+
11
19
  ## What the gate checks
12
20
 
13
21
  The `package_inventory_stale_blocker.py` hook runs on every Write whose target is a new file (a path not yet on disk). It:
@@ -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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Trigger:** `/anthropic-plan`, `/plan`, "plan this first", "think before coding", "make a plan", "scope this out", "don't code yet", and non-trivial requests that need source-grounded design before build work.
4
4
 
5
- Creates a repo-local plan packet under `docs/plans/<slug>/` by running the `plan-packet.mjs` workflow. The packet holds context, spec, implementation steps, validation, and a handoff prompt for the build agent. The skill stops before any production code changes.
5
+ Creates a repo-local plan packet under `docs/plans/<slug>/` by running the `plan-packet.mjs` workflow. The skill first drafts a short starting plan and gets the user's approval in plan mode (`EnterPlanMode` / `ExitPlanMode`); on approval it runs the workflow. The packet holds context, spec, implementation steps, validation, and a handoff prompt for the build agent. The skill stops before any production code changes.
6
6
 
7
7
  ## Subdirectories
8
8
 
@@ -5,9 +5,20 @@ description: Workflow-backed implementation planning that creates a deep repo-lo
5
5
 
6
6
  # Anthropic Plan
7
7
 
8
- Create a source-grounded plan packet through the Claude Code Workflow runtime. The output is a repo-local `docs/plans/<slug>/` folder with context, spec, implementation, validation, and handoff docs. Stop before implementation.
8
+ Create a source-grounded plan packet through the Claude Code Workflow runtime. First draft a short starting plan and get it approved in plan mode; then the workflow builds the full repo-local `docs/plans/<slug>/` packet context, spec, implementation, validation, and handoff docs. Stop before implementation.
9
9
 
10
- ## Isolate first
10
+ ## Plan first (plan mode)
11
+
12
+ Before any worktree or workflow, build a short starting plan in plan mode and get the user's approval. This is a cheap gate: it confirms the direction before the workflow spends tokens building the full packet.
13
+
14
+ 1. Enter plan mode with `EnterPlanMode` if the session is not already in it.
15
+ 2. Read the handful of files closest to the request — the entry points, the modules the change touches, the tests that cover them — enough to ground the approach in real source.
16
+ 3. Write a short plan: the goal in a sentence or two, the approach, the files the build will touch, and the main risks or open choices. Use `AskUserQuestion` for any product choice that local context cannot settle.
17
+ 4. Call `ExitPlanMode` to hand the plan to the user for approval.
18
+
19
+ On approval, fold the approved scope and direction into the `task` payload at launch, then continue with the steps below. If the user revises the plan, fold the changes in and present it again. Do not isolate a worktree or launch the workflow until the starting plan is approved.
20
+
21
+ ## Isolate the session
11
22
 
12
23
  The workflow's background subagents write the packet into the working tree. A background session that has not isolated into a worktree cannot write a shared checkout — the background-isolation guard rejects the write. So put the session in a worktree before launching the workflow:
13
24
 
@@ -27,6 +38,8 @@ Workflow({
27
38
  })
28
39
  ```
29
40
 
41
+ The `task` string carries the user request together with the approved starting plan, so the packet reflects the direction the user signed off on.
42
+
30
43
  If the Workflow tool is unavailable, say `anthropic-plan requires the Workflow tool; aborting` and stop.
31
44
 
32
45
  ## Self-healing writes
@@ -266,9 +266,12 @@ describes the narrowest rm auto-allow path — a standalone Bash call whose targ
266
266
  resolves inside the ephemeral namespace (`/tmp`, `/temp`, the OS temp root, or the
267
267
  run worktree) — and a compound path that accepts an rm joined with benign
268
268
  reporting segments when every rm target is an absolute ephemeral path. Both of
269
- those paths fail closed on `$(...)` substitution, backtick subshells, and any `$`
270
- in the target including `$CLAUDE_JOB_DIR` so neither resolves an environment
271
- variable. A third, broad path matches only when the command itself declares an
269
+ those paths fail closed on `$(...)` substitution and backtick subshells. The
270
+ compound path also fails closed on any `$` in the target — including
271
+ `$CLAUDE_JOB_DIR`. The standalone path declines a `$`-bearing target only when
272
+ the literal path is not already under an ephemeral root, so it does not by
273
+ itself stop a `$VAR` that expands inside an ephemeral root. A third, broad path
274
+ matches only when the command itself declares an
272
275
  ephemeral working directory (it `cd`s into one, or runs under one): that
273
276
  cwd-scoped path resolves the target against the declared cwd, fails closed on
274
277
  `$(...)`, backticks, and unknown variables, and resolves the known temporary
@@ -31,6 +31,13 @@ skill still runs teardown (revoke permissions, final report).
31
31
  cannot confirm the PR left draft state (`gh pr ready` errored, or the draft
32
32
  re-query still reports true). The workflow does not report `converged: true`;
33
33
  the run ends with a `blocker` naming the failed ready transition.
34
+ - **Clean-audit post blocked** — every review lens is clean on HEAD, but the
35
+ CLEAN bugteam review cannot be posted (the `post_audit_thread.py` post is
36
+ denied, errors, or its agent dies). The convergence gate's bugteam-review
37
+ check can never pass without that CLEAN review, so the run stops rather than
38
+ re-converge to the iteration cap. The `blocker` names the post failure and the
39
+ HEAD. Unblock by allowing `post_audit_thread.py` with a Bash permission rule,
40
+ or post the CLEAN review by hand, then re-run.
34
41
 
35
42
  ## Not a blocker (the run continues)
36
43
 
@@ -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/);