claude-dev-env 2.29.0 → 2.30.1

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 (60) hide show
  1. package/.agents/agents/pr-description-writer.md +21 -6
  2. package/.agents/agents/reference/README.md +6 -0
  3. package/.agents/agents/reference/pr-description-illustrative-voice.md +45 -0
  4. package/.agents/agents/reference/pr-description-verification.md +19 -0
  5. package/.agents/agents/test_pr_description_writer_style.py +47 -0
  6. package/.agents/skills/e-simplify/SKILL.md +20 -8
  7. package/_shared/pr-loop/code-rules-gate.md +1 -1
  8. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +8 -8
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +2 -2
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +1 -1
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
  13. package/docs/CODE_RULES.md +10 -0
  14. package/hooks/blocking/code_rules_enforcer.py +435 -318
  15. package/hooks/blocking/shell_substitution_blocker.py +106 -30
  16. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +14 -0
  17. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +1 -1
  18. package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +1 -1
  19. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +117 -22
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +1 -1
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +1 -1
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +1 -1
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +1 -1
  24. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +1 -1
  25. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +1 -1
  26. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +1 -1
  27. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +2 -2
  28. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +3 -3
  29. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +1 -1
  30. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +1 -1
  31. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +117 -38
  32. package/hooks/blocking/test_code_rules_enforcer_output_snapshot.py +121 -0
  33. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +17 -15
  34. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +4 -2
  35. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
  36. package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
  37. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +4 -4
  38. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +2 -2
  39. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +2 -2
  40. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -2
  41. package/hooks/blocking/test_code_rules_validation_phase.py +332 -0
  42. package/hooks/blocking/test_fable_spawn_gate.py +1 -1
  43. package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
  44. package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
  45. package/hooks/hooks.json +1 -1
  46. package/hooks/hooks_constants/code_rules_enforcer_constants.py +7 -0
  47. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
  48. package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
  49. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +12 -0
  50. package/hooks/hooks_constants/validation_phase_constants.py +34 -0
  51. package/hooks/session/task_tool_prompt.py +37 -0
  52. package/hooks/session/test_task_tool_prompt.py +65 -0
  53. package/hooks/validators/README.md +1 -1
  54. package/hooks/validators/config/hook_timing_harness_constants.py +16 -0
  55. package/hooks/validators/hook_timing_harness.py +287 -0
  56. package/hooks/validators/test_hook_timing_harness.py +251 -0
  57. package/package.json +1 -1
  58. package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
  59. package/hooks/session/task_list_loop_starter.py +0 -45
  60. package/hooks/session/test_task_list_loop_starter.py +0 -63
@@ -0,0 +1,332 @@
1
+ """Tests for the validate_content edit-lane and full-gate phase split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import contextlib
7
+ import importlib
8
+ import importlib.util
9
+ import inspect
10
+ import re
11
+ import shutil
12
+ import sys
13
+ import tempfile
14
+ import textwrap
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+ from unittest.mock import DEFAULT, MagicMock, patch
18
+
19
+ import pytest
20
+
21
+
22
+ def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
23
+ """Load and execute the module found at module_path, under module_name."""
24
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
25
+ assert specification is not None
26
+ assert specification.loader is not None
27
+ module = importlib.util.module_from_spec(specification)
28
+ specification.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
33
+ _HOOKS_DIRECTORY = _BLOCKING_DIRECTORY.parent
34
+ _PACKAGE_ROOT = _HOOKS_DIRECTORY.parent
35
+ _PR_LOOP_SCRIPTS_DIRECTORY = str(_PACKAGE_ROOT / "_shared" / "pr-loop" / "scripts")
36
+
37
+ code_rules_enforcer = _load_module_from_path(
38
+ "code_rules_enforcer", _BLOCKING_DIRECTORY / "code_rules_enforcer.py"
39
+ )
40
+ validation_phase_constants = _load_module_from_path(
41
+ "validation_phase_constants",
42
+ _HOOKS_DIRECTORY / "hooks_constants" / "validation_phase_constants.py",
43
+ )
44
+
45
+ if _PR_LOOP_SCRIPTS_DIRECTORY not in sys.path:
46
+ sys.path.insert(0, _PR_LOOP_SCRIPTS_DIRECTORY)
47
+ enforcer_loading = importlib.import_module("code_rules_gate_parts.enforcer_loading")
48
+ gate_running = importlib.import_module("code_rules_gate_parts.gate_running")
49
+ repo_test_helpers = importlib.import_module("code_rules_gate_parts.tests._repo_test_helpers")
50
+
51
+ PRODUCTION_FILE_PATH = "packages/app/services.py"
52
+ SAMPLE_PYTHON_CONTENT = "def add_one(value: int) -> int:\n return value + 1\n"
53
+ UNKNOWN_PHASE_NAME = "not_a_real_phase"
54
+ HOOK_INFRASTRUCTURE_FILE_PATH = "/repo/packages/claude-dev-env/hooks/blocking/example.py"
55
+ _HOOK_MODULE_DUPLICATE_BODY_SOURCE = (
56
+ "def compute_shipping_total(order_total: float, tax_rate: float) -> float:\n"
57
+ " taxed_total = order_total * (1 + tax_rate)\n"
58
+ " rounded_total = round(taxed_total, 2)\n"
59
+ " return rounded_total\n"
60
+ )
61
+
62
+
63
+ def test_all_validation_phases_holds_exactly_the_two_phases() -> None:
64
+ assert validation_phase_constants.ALL_VALIDATION_PHASES == {
65
+ validation_phase_constants.EDIT_LANE_PHASE,
66
+ validation_phase_constants.FULL_GATE_PHASE,
67
+ }
68
+
69
+
70
+ def test_unknown_phase_raises_value_error_naming_the_rejected_value() -> None:
71
+ with pytest.raises(ValueError, match=UNKNOWN_PHASE_NAME):
72
+ code_rules_enforcer.validate_content_for_phase(
73
+ SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH, phase=UNKNOWN_PHASE_NAME
74
+ )
75
+
76
+
77
+ def test_validate_content_for_phase_requires_phase_keyword() -> None:
78
+ with pytest.raises(TypeError):
79
+ code_rules_enforcer.validate_content_for_phase(SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH)
80
+
81
+
82
+ def _full_gate_only_default_kwargs() -> dict[str, object]:
83
+ return {
84
+ each_name: DEFAULT
85
+ for each_name in validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
86
+ }
87
+
88
+
89
+ def _patched_full_gate_only_checks() -> contextlib.AbstractContextManager[dict[str, MagicMock]]:
90
+ """Return the patch.multiple context manager for the six full-gate-only checks.
91
+
92
+ ::
93
+
94
+ mypy's patch.multiple stub only accepts an ``Any | str`` target, so a
95
+ dynamically loaded ``ModuleType`` target needs a local type: ignore.
96
+ """
97
+ return patch.multiple( # type: ignore[call-overload] # mypy stub rejects ModuleType targets
98
+ code_rules_enforcer, **_full_gate_only_default_kwargs()
99
+ )
100
+
101
+
102
+ def _configure_full_gate_only_mock_returns(all_mocks: dict[str, MagicMock]) -> None:
103
+ for each_name, each_mock in all_mocks.items():
104
+ each_mock.return_value = None if each_name == "advise_cross_skill_duplicate_helper" else []
105
+
106
+
107
+ def test_edit_lane_calls_none_of_the_six_full_gate_only_checks() -> None:
108
+ with _patched_full_gate_only_checks() as all_mocks:
109
+ _configure_full_gate_only_mock_returns(all_mocks)
110
+ code_rules_enforcer.validate_content_for_edit_lane(
111
+ SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
112
+ )
113
+ for each_name, each_mock in all_mocks.items():
114
+ assert each_mock.call_count == 0, f"edit lane must not call {each_name}"
115
+
116
+
117
+ def test_full_gate_calls_all_six_full_gate_only_checks() -> None:
118
+ with _patched_full_gate_only_checks() as all_mocks:
119
+ _configure_full_gate_only_mock_returns(all_mocks)
120
+ code_rules_enforcer.validate_content_for_full_gate(
121
+ SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
122
+ )
123
+ for each_name, each_mock in all_mocks.items():
124
+ assert each_mock.call_count >= 1, f"full gate must call {each_name}"
125
+
126
+
127
+ def test_both_phases_report_identical_findings_with_the_six_spied_out() -> None:
128
+ with _patched_full_gate_only_checks() as all_mocks:
129
+ _configure_full_gate_only_mock_returns(all_mocks)
130
+ edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
131
+ SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
132
+ )
133
+ full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
134
+ SAMPLE_PYTHON_CONTENT, PRODUCTION_FILE_PATH
135
+ )
136
+ assert edit_lane_issues == full_gate_issues
137
+
138
+
139
+ def test_no_caller_should_reach_a_phase_less_validator() -> None:
140
+ assert not hasattr(code_rules_enforcer, "validate_content"), (
141
+ "validate_content must not survive as a callable on the enforcer module"
142
+ )
143
+ enforcer_source = inspect.getsource(code_rules_enforcer)
144
+ assert re.search(r"\bvalidate_content\(", enforcer_source) is None, (
145
+ "no call site in code_rules_enforcer.py may reach a phase-less validator"
146
+ )
147
+
148
+
149
+ def test_precheck_selects_the_full_gate() -> None:
150
+ precheck_source = inspect.getsource(code_rules_enforcer._run_precheck)
151
+ assert "validate_content_for_full_gate(" in precheck_source, (
152
+ "the --check precheck must run the full-gate verdict"
153
+ )
154
+
155
+
156
+ def test_codex_patch_issues_selects_the_edit_lane() -> None:
157
+ """Pin the apply_patch mutation path to the edit-lane verdict."""
158
+ codex_patch_issues_source = inspect.getsource(code_rules_enforcer._codex_patch_issues)
159
+ assert "validate_content_for_edit_lane(" in codex_patch_issues_source, (
160
+ "the apply_patch branch must run the edit-lane verdict"
161
+ )
162
+
163
+
164
+ def test_forecast_full_file_violations_selects_the_edit_lane() -> None:
165
+ """Pin the full-file forecast pass to the edit-lane verdict."""
166
+ forecast_source = inspect.getsource(code_rules_enforcer._forecast_full_file_violations)
167
+ assert "validate_content_for_edit_lane(" in forecast_source, (
168
+ "the full-file forecast pass must run the edit-lane verdict"
169
+ )
170
+
171
+
172
+ def test_report_blocking_violations_selects_the_edit_lane() -> None:
173
+ """Pin the PreToolUse Write and Edit main path to the edit-lane verdict."""
174
+ report_blocking_violations_source = inspect.getsource(
175
+ code_rules_enforcer._report_blocking_violations
176
+ )
177
+ assert "validate_content_for_edit_lane(" in report_blocking_violations_source, (
178
+ "the PreToolUse Write and Edit main path must run the edit-lane verdict"
179
+ )
180
+
181
+
182
+ def _write_scoped_service_file_with_a_committed_baseline(repository_root: Path) -> Path:
183
+ repo_test_helpers.init_repository(repository_root)
184
+ return repo_test_helpers.write_commit_and_stage_change(
185
+ repository_root,
186
+ "service.py",
187
+ "def read_port() -> int:\n return 0\n",
188
+ "def read_port() -> int:\n port_number = 9999\n return port_number\n",
189
+ )
190
+
191
+
192
+ def test_hook_infrastructure_edit_lane_skips_the_cross_file_duplicate_check() -> None:
193
+ with patch.object(
194
+ code_rules_enforcer, "check_duplicate_function_body_across_files"
195
+ ) as mock_check_duplicate_across_files:
196
+ mock_check_duplicate_across_files.return_value = []
197
+ code_rules_enforcer._hook_infrastructure_blocking_issues(
198
+ SAMPLE_PYTHON_CONTENT,
199
+ HOOK_INFRASTRUCTURE_FILE_PATH,
200
+ phase=validation_phase_constants.EDIT_LANE_PHASE,
201
+ )
202
+ assert mock_check_duplicate_across_files.call_count == 0, (
203
+ "the hook-infrastructure edit lane must not call the cross-file duplicate-body check"
204
+ )
205
+
206
+
207
+ def test_hook_infrastructure_full_gate_calls_the_cross_file_duplicate_check() -> None:
208
+ with patch.object(
209
+ code_rules_enforcer, "check_duplicate_function_body_across_files"
210
+ ) as mock_check_duplicate_across_files:
211
+ mock_check_duplicate_across_files.return_value = []
212
+ code_rules_enforcer._hook_infrastructure_blocking_issues(
213
+ SAMPLE_PYTHON_CONTENT,
214
+ HOOK_INFRASTRUCTURE_FILE_PATH,
215
+ phase=validation_phase_constants.FULL_GATE_PHASE,
216
+ )
217
+ assert mock_check_duplicate_across_files.call_count == 1, (
218
+ "the hook-infrastructure full gate must still call the cross-file duplicate-body check"
219
+ )
220
+
221
+
222
+ def _write_duplicate_helper_pair_under_a_non_test_named_tempdir() -> tuple[Path, str]:
223
+ """Write a hook-infra sibling pair under a ``tempfile.mkdtemp()`` root.
224
+
225
+ ``tempfile.mkdtemp()`` names its directory ``tmp<random>``, carrying no
226
+ ``test_`` segment, unlike pytest's own ``tmp_path`` fixture — whose
227
+ directory embeds the test's name and so reads as a test path to
228
+ ``is_test_file``'s full-path substring match, silently exempting every
229
+ check the caller means to exercise.
230
+ """
231
+ base_directory = Path(tempfile.mkdtemp())
232
+ hook_blocking_directory = base_directory / "packages" / "claude-dev-env" / "hooks" / "blocking"
233
+ hook_blocking_directory.mkdir(parents=True)
234
+ (hook_blocking_directory / "shipping_helper.py").write_text(
235
+ _HOOK_MODULE_DUPLICATE_BODY_SOURCE, encoding="utf-8"
236
+ )
237
+ return base_directory, str(hook_blocking_directory / "shipping_target.py")
238
+
239
+
240
+ def test_full_gate_still_catches_a_helper_duplicated_across_two_hook_modules() -> None:
241
+ base_directory, target_path = _write_duplicate_helper_pair_under_a_non_test_named_tempdir()
242
+ try:
243
+ full_gate_issues = code_rules_enforcer._hook_infrastructure_blocking_issues(
244
+ _HOOK_MODULE_DUPLICATE_BODY_SOURCE,
245
+ target_path,
246
+ phase=validation_phase_constants.FULL_GATE_PHASE,
247
+ )
248
+ edit_lane_issues = code_rules_enforcer._hook_infrastructure_blocking_issues(
249
+ _HOOK_MODULE_DUPLICATE_BODY_SOURCE,
250
+ target_path,
251
+ phase=validation_phase_constants.EDIT_LANE_PHASE,
252
+ )
253
+ finally:
254
+ shutil.rmtree(base_directory, ignore_errors=False)
255
+
256
+ assert any("duplicates" in each_issue for each_issue in full_gate_issues), (
257
+ "the full gate must still catch a helper duplicated across two hook "
258
+ f"modules, got: {full_gate_issues!r}"
259
+ )
260
+ assert not any("duplicates" in each_issue for each_issue in edit_lane_issues), (
261
+ "the edit lane must not run the cross-file duplicate-body check against "
262
+ f"hook infrastructure, got: {edit_lane_issues!r}"
263
+ )
264
+
265
+
266
+ def test_gate_running_selects_the_full_gate(tmp_path: Path) -> None:
267
+ repository_root = tmp_path / "repo"
268
+ repository_root.mkdir()
269
+ file_path = _write_scoped_service_file_with_a_committed_baseline(repository_root)
270
+
271
+ loaded_validate_content = enforcer_loading.load_validate_content_for_full_gate()
272
+ assert loaded_validate_content.__name__ == "validate_content_for_full_gate"
273
+
274
+ scoped_violations = gate_running._scoped_violations_for_file(
275
+ validate_content=loaded_validate_content,
276
+ resolved_path=file_path,
277
+ repository_root=repository_root,
278
+ all_added_lines_for_file={2, 3},
279
+ should_read_staged_content=True,
280
+ )
281
+ assert scoped_violations is not None
282
+ blocking_violations, _advisory_violations = scoped_violations
283
+ assert any("9999" in each_issue for each_issue in blocking_violations)
284
+
285
+
286
+ ALL_CHECK_FUNCTION_NAME_PREFIXES = ("check_", "advise_")
287
+
288
+
289
+ def _check_names_called_in(enforcer_function_name: str) -> set[str]:
290
+ """Return the check-function names called directly inside a phase router.
291
+
292
+ Parses the router's own source and collects every direct call to a
293
+ module-level name beginning with a check prefix, so a roster constant can
294
+ assert against the calls production really makes rather than against a
295
+ second hand-written literal.
296
+
297
+ Args:
298
+ enforcer_function_name: The attribute name of the router on the
299
+ enforcer module, such as ``_python_full_gate_only_issues``.
300
+
301
+ Returns:
302
+ The set of check-function names the router calls directly.
303
+ """
304
+ router_source = textwrap.dedent(
305
+ inspect.getsource(getattr(code_rules_enforcer, enforcer_function_name))
306
+ )
307
+ return {
308
+ each_node.func.id
309
+ for each_node in ast.walk(ast.parse(router_source))
310
+ if isinstance(each_node, ast.Call)
311
+ and isinstance(each_node.func, ast.Name)
312
+ and each_node.func.id.startswith(ALL_CHECK_FUNCTION_NAME_PREFIXES)
313
+ }
314
+
315
+
316
+ def test_full_gate_only_roster_matches_the_checks_the_router_calls() -> None:
317
+ """The roster constant tracks the calls in _python_full_gate_only_issues."""
318
+ assert (
319
+ _check_names_called_in("_python_full_gate_only_issues")
320
+ == validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
321
+ ), "ALL_FULL_GATE_ONLY_CHECK_NAMES must name exactly the router's own calls"
322
+
323
+
324
+ def test_hook_infrastructure_edit_lane_roster_matches_the_checks_the_router_calls() -> None:
325
+ """The roster constant tracks the always-run calls in the hook-infrastructure router."""
326
+ all_always_run_check_names = _check_names_called_in(
327
+ "_hook_infrastructure_blocking_issues"
328
+ ) - validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
329
+ assert (
330
+ all_always_run_check_names
331
+ == validation_phase_constants.ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES
332
+ ), "ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES must name exactly the router's own calls"
@@ -72,7 +72,7 @@ _ADVISOR_LIFECYCLE_TEXT = (_ADVISOR_REFERENCE_DIR / "lifecycle.md").read_text(
72
72
  _ADVISOR_WARM_UP_TEXT = (_ADVISOR_REFERENCE_DIR / "warm-up.md").read_text(
73
73
  encoding="utf-8"
74
74
  )
75
- _ALL_CONSUMING_SKILL_NAMES = ("team-advisor", "orchestrator", "orchestrator-refresh")
75
+ _ALL_CONSUMING_SKILL_NAMES = ("team-advisor",)
76
76
  _ALL_CONSUMING_SKILL_PATHS = tuple(
77
77
  _PACKAGE_ROOT / ".agents" / "skills" / each_skill_name / "SKILL.md"
78
78
  for each_skill_name in _ALL_CONSUMING_SKILL_NAMES
@@ -734,16 +734,31 @@ def test_dispatcher_multi_edit_applies_only_group_b() -> None:
734
734
  )
735
735
 
736
736
 
737
- def test_proceed_after_run_all_validators_removal_allows() -> None:
738
- """The PreToolUse dispatcher allows a Python edit that the removed gate would have processed.
739
-
740
- The inline run_all_validators runner was a PostToolUse gate removed in Stage 4;
741
- it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
742
- A Python Write payload that run_all_validators would have flagged (mypy errors, for
743
- instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
744
- dispatcher covers only its 23 hosted blocking hooks — none of which includes the
745
- validators runner.
737
+ def _assert_roster_names_no_run_all_validators_entry() -> None:
738
+ """Assert no hosted-roster ``script_relative_path`` names run_all_validators."""
739
+ all_roster_script_paths = {
740
+ each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
741
+ }
742
+ assert not any(
743
+ "run_all_validators" in each_script_path
744
+ for each_script_path in all_roster_script_paths
745
+ ), (
746
+ "ALL_HOSTED_HOOK_ENTRIES must name no run_all_validators entry, got: "
747
+ f"{sorted(all_roster_script_paths)}"
748
+ )
749
+
750
+
751
+ def test_run_all_validators_is_not_hosted_by_pre_tool_use_dispatcher() -> None:
752
+ """The main PreToolUse dispatcher's hosted roster names no run_all_validators entry.
753
+
754
+ ``hooks.json`` registers the ``run_all_validators`` runner as its own
755
+ PreToolUse ``Write|Edit`` command, separate from the main dispatcher. A
756
+ Python Write payload that ``run_all_validators`` would flag (a type error,
757
+ for instance) still produces ALLOW from the main dispatcher, because its
758
+ roster carries no entry for that runner.
746
759
  """
760
+ _assert_roster_names_no_run_all_validators_entry()
761
+
747
762
  python_content_with_type_error = (
748
763
  "def add_one(value: int) -> int:\n"
749
764
  " return value + 1\n\n\n"
@@ -754,8 +769,8 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
754
769
  is_deny, _reason = _parse_hook_decision(dispatcher_result)
755
770
  assert not is_deny, (
756
771
  "PreToolUse dispatcher must allow a Python Write with a type error — "
757
- "mypy validation is PostToolUse-only; the removed run_all_validators gate "
758
- "was never a PreToolUse hook"
772
+ "run_all_validators runs as its own separate PreToolUse command, not "
773
+ "through this dispatcher's roster"
759
774
  )
760
775
  assert dispatcher_result.returncode == 0, (
761
776
  f"Dispatcher must exit 0, got {dispatcher_result.returncode}"
@@ -122,3 +122,72 @@ def test_allows_arithmetic_expansion() -> None:
122
122
  "tool_input": {"command": "echo $((2+2))"},
123
123
  }
124
124
  assert _run_hook(payload).stdout == ""
125
+
126
+
127
+ def test_allows_arithmetic_expansion_with_nested_parens() -> None:
128
+ payload = {
129
+ "tool_name": "Bash",
130
+ "tool_input": {"command": "echo $(( (1+2) * 3 ))"},
131
+ }
132
+ assert _run_hook(payload).stdout == ""
133
+
134
+
135
+ def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
136
+ payload = {
137
+ "tool_name": "Bash",
138
+ "tool_input": {"command": "echo $((echo hi))"},
139
+ }
140
+ assert _run_hook(payload).stdout == ""
141
+
142
+
143
+ def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
144
+ payload = {
145
+ "tool_name": "Bash",
146
+ "tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
147
+ }
148
+ assert _run_hook(payload).stdout == ""
149
+
150
+
151
+ def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
152
+ payload = {
153
+ "tool_name": "Bash",
154
+ "tool_input": {"command": "echo $((cd /tmp) && pwd)"},
155
+ }
156
+ response = json.loads(_run_hook(payload).stdout)
157
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
158
+
159
+
160
+ def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
161
+ payload = {
162
+ "tool_name": "Bash",
163
+ "tool_input": {"command": "echo $((1+2) )"},
164
+ }
165
+ response = json.loads(_run_hook(payload).stdout)
166
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
167
+
168
+
169
+ def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
170
+ payload = {
171
+ "tool_name": "Bash",
172
+ "tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
173
+ }
174
+ response = json.loads(_run_hook(payload).stdout)
175
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
176
+
177
+
178
+ def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
179
+ payload = {
180
+ "tool_name": "Bash",
181
+ "tool_input": {"command": "echo $(( `id -u` + 1 ))"},
182
+ }
183
+ response = json.loads(_run_hook(payload).stdout)
184
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
185
+
186
+
187
+ def test_denies_nested_command_substitution_without_arithmetic() -> None:
188
+ payload = {
189
+ "tool_name": "Bash",
190
+ "tool_input": {"command": "echo $(echo $(whoami))"},
191
+ }
192
+ response = json.loads(_run_hook(payload).stdout)
193
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
package/hooks/hooks.json CHANGED
@@ -171,7 +171,7 @@
171
171
  },
172
172
  {
173
173
  "type": "command",
174
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_list_loop_starter.py",
174
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_tool_prompt.py",
175
175
  "timeout": 10
176
176
  },
177
177
  {
@@ -6,6 +6,13 @@ Extracted from code_rules_enforcer.py to satisfy the constants-location rule.
6
6
  import re
7
7
  import tokenize
8
8
 
9
+ from hooks_constants.validation_phase_constants import ( # noqa: F401 re-exported for code_rules_enforcer.py
10
+ ALL_VALIDATION_PHASES,
11
+ EDIT_LANE_PHASE,
12
+ FULL_GATE_PHASE,
13
+ UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
14
+ )
15
+
9
16
  ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS: tuple[type[BaseException], ...] = (
10
17
  tokenize.TokenError,
11
18
  IndentationError,
@@ -41,7 +41,10 @@ PERMISSION_DECISION_KEY = "permissionDecision"
41
41
  DENY_DECISION = "deny"
42
42
  PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
43
43
 
44
- DOLLAR_PAREN_PATTERN = re.compile(r"\$\((?!\()")
44
+ DOLLAR_PAREN_PATTERN = re.compile(r"\$\(")
45
+ OPEN_PAREN_CHARACTER = "("
46
+ CLOSE_PAREN_CHARACTER = ")"
47
+ PAREN_DEPTH_AFTER_DOUBLE_OPEN = 2
45
48
  EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
46
49
  PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
47
50
  SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
@@ -0,0 +1,13 @@
1
+ """Directive text for the task_tool_prompt SessionStart hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "TASK_TOOL_DIRECTIVE",
7
+ ]
8
+
9
+ TASK_TOOL_DIRECTIVE = (
10
+ "TASK TRACKING: Track this session's work with the session's task or todo "
11
+ "tracking tool, whichever this host provides. Create tasks at the start "
12
+ "of the session, then update them as work proceeds."
13
+ )
@@ -17,12 +17,16 @@ if str(_HOOKS_ROOT) not in sys.path:
17
17
  sys.path.insert(0, str(_HOOKS_ROOT))
18
18
 
19
19
  from hooks_constants.code_rules_enforcer_constants import (
20
+ ALL_VALIDATION_PHASES,
20
21
  CONFIG_DIRECTORY_SEGMENT,
21
22
  CONSTANTS_MODULE_SUFFIX,
23
+ EDIT_LANE_PHASE,
24
+ FULL_GATE_PHASE,
22
25
  FUNCTION_WITH_JSDOC_PATTERN,
23
26
  JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
24
27
  RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
25
28
  SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
29
+ UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
26
30
  )
27
31
 
28
32
 
@@ -98,3 +102,11 @@ def test_schema_property_key_pattern_matches_only_the_options_key() -> None:
98
102
  def test_constants_module_suffix_and_config_directory_segment_are_defined() -> None:
99
103
  assert CONSTANTS_MODULE_SUFFIX == "_constants.py"
100
104
  assert CONFIG_DIRECTORY_SEGMENT == "config"
105
+
106
+
107
+ def test_validation_phase_constants_are_reexported_and_render() -> None:
108
+ assert ALL_VALIDATION_PHASES == {EDIT_LANE_PHASE, FULL_GATE_PHASE}
109
+ rendered_message = UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE.format(
110
+ phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
111
+ )
112
+ assert "bogus_phase" in rendered_message
@@ -0,0 +1,34 @@
1
+ """Constants for the validate_content edit-lane and full-gate phase split.
2
+
3
+ The edit lane and the full gate share every check except six that read
4
+ sibling modules from disk. These names identify each phase, name the checks
5
+ the full gate alone runs, name the hook-infrastructure checks the edit lane
6
+ still runs, and template the error for an unrecognized phase.
7
+ """
8
+
9
+ EDIT_LANE_PHASE: str = "edit_lane"
10
+ FULL_GATE_PHASE: str = "full_gate"
11
+ ALL_VALIDATION_PHASES: frozenset[str] = frozenset({EDIT_LANE_PHASE, FULL_GATE_PHASE})
12
+
13
+ ALL_FULL_GATE_ONLY_CHECK_NAMES: frozenset[str] = frozenset(
14
+ {
15
+ "check_duplicate_function_body_across_files",
16
+ "advise_cross_skill_duplicate_helper",
17
+ "check_public_function_missing_paired_test",
18
+ "check_test_file_omits_module_public_function",
19
+ "check_orphan_css_classes",
20
+ "check_config_duplicate_path_anchor",
21
+ }
22
+ )
23
+
24
+ ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES: frozenset[str] = frozenset(
25
+ {
26
+ "check_same_file_inline_duplicate_body",
27
+ "check_zero_payload_function_alias",
28
+ "check_unanchored_command_dispatch",
29
+ }
30
+ )
31
+
32
+ UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE: str = (
33
+ "unrecognized validation phase {phase!r}; expected one of {all_phases}"
34
+ )
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — direct the session to track work with its task tool.
3
+
4
+ At session start this hook emits an ``additionalContext`` directive asking
5
+ the session to track its work with the task or todo tracking tool its host
6
+ provides: create tasks at the start of the session, then update them as
7
+ work proceeds.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+
15
+ import _path_setup # noqa: F401
16
+
17
+ from hooks_constants.task_tool_prompt_constants import TASK_TOOL_DIRECTIVE
18
+
19
+
20
+ def build_session_directive() -> str:
21
+ """Return the task-tool directive emitted at session start."""
22
+ return TASK_TOOL_DIRECTIVE
23
+
24
+
25
+ def main() -> None:
26
+ """Emit the task-tool directive as SessionStart additionalContext."""
27
+ payload = {
28
+ "hookSpecificOutput": {
29
+ "hookEventName": "SessionStart",
30
+ "additionalContext": build_session_directive(),
31
+ }
32
+ }
33
+ sys.stdout.write(json.dumps(payload) + "\n")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()