claude-dev-env 2.31.1 → 2.32.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 (46) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +1 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +13 -7
  6. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  7. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  8. package/hooks/blocking/stop_dispatcher.py +6 -9
  9. package/hooks/blocking/tdd_enforcer.py +3 -1
  10. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  11. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  12. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  13. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  14. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
  15. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
  16. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  17. package/hooks/blocking/test_state_description_blocker.py +56 -71
  18. package/hooks/blocking/test_tdd_enforcer.py +67 -1
  19. package/hooks/hooks.json +2 -2
  20. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  21. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  22. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  23. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  24. package/hooks/hooks_constants/state_file_lock.py +76 -0
  25. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  26. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  27. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  28. package/hooks/observability/session_file_edit_tracker.py +2 -55
  29. package/hooks/observability/test_failure_recorder.py +219 -0
  30. package/hooks/observability/test_test_failure_recorder.py +173 -0
  31. package/hooks/validators/config/directory_exemption_constants.py +0 -9
  32. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  33. package/hooks/validators/mypy_integration.py +10 -36
  34. package/hooks/validators/project_roots.py +58 -0
  35. package/hooks/validators/run_all_validators.py +87 -33
  36. package/hooks/validators/system_temporary_roots.py +1 -1
  37. package/hooks/validators/test_run_all_validators.py +22 -0
  38. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  39. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  40. package/package.json +1 -1
  41. package/rules/shell-invocation.md +1 -1
  42. package/rules/testing.md +4 -0
  43. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
  44. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  45. package/scripts/shared_tree_paths.py +59 -25
  46. package/scripts/test_shared_tree_paths.py +30 -0
@@ -0,0 +1,249 @@
1
+ """Behavioral tests for validator coverage across every mutation tool.
2
+
3
+ The PreToolUse gate has to reach a MultiEdit and a Codex apply_patch the same
4
+ way it reaches a Write and an Edit. Two things carry that: the hooks.json
5
+ matcher the harness dispatches on, and the payload shape the gate reconstructs
6
+ post-edit content from.
7
+ """
8
+
9
+ import json
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
16
+ ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
17
+ )
18
+
19
+ from .run_all_validators import run_validators_entrypoint_subprocess
20
+
21
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
22
+
23
+ HOOKS_JSON_PATH = Path(__file__).resolve().parents[1] / "hooks.json"
24
+ VALIDATORS_RUNNER_COMMAND_MARKER = "validators.run_all_validators"
25
+ MATCHER_TOOL_NAME_SEPARATOR = "|"
26
+ DENY_DECISION_FRAGMENT = '"permissionDecision": "deny"'
27
+ MAGIC_VALUE_VALIDATOR_NAME = "Magic Values"
28
+
29
+ CLEAN_MODULE_SOURCE = (
30
+ "def clean_marker() -> int:\n computed_value = 1\n return computed_value\n"
31
+ )
32
+ CLEAN_SECOND_MODULE_SOURCE = (
33
+ "def second_marker() -> int:\n counted_value = 1\n return counted_value\n"
34
+ )
35
+ CLEAN_RETURN_LINE = " return computed_value\n"
36
+ MAGIC_VALUE_RETURN_LINE = " return computed_value * 199\n"
37
+ CLEAN_REWRITTEN_RETURN_LINE = " return computed_value + 1\n"
38
+
39
+ UPDATE_PATCH_TEMPLATE = (
40
+ "*** Begin Patch\n"
41
+ "*** Update File: {relative_path}\n"
42
+ "@@\n"
43
+ "-{removed_line}"
44
+ "+{added_line}"
45
+ "*** End Patch\n"
46
+ )
47
+ ADD_VIOLATING_FILE_PATCH_COMMAND = (
48
+ "*** Begin Patch\n"
49
+ "*** Add File: added_module.py\n"
50
+ "+def added_marker() -> int:\n"
51
+ "+ return 199\n"
52
+ "*** End Patch\n"
53
+ )
54
+ DELETE_FILE_PATCH_COMMAND = (
55
+ "*** Begin Patch\n*** Delete File: legacy_module.py\n*** End of File\n*** End Patch\n"
56
+ )
57
+ UNPARSEABLE_PATCH_COMMAND = "not a codex patch at all\n"
58
+ TWO_FILE_PATCH_COMMAND = (
59
+ "*** Begin Patch\n"
60
+ "*** Update File: first_module.py\n"
61
+ "@@\n"
62
+ "- return computed_value\n"
63
+ "+ return computed_value + 1\n"
64
+ "*** Update File: second_module.py\n"
65
+ "@@\n"
66
+ "- return counted_value\n"
67
+ "+ return counted_value * 199\n"
68
+ "*** End Patch\n"
69
+ )
70
+
71
+
72
+ def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
73
+ """Drive the real --pre-tool-use entry point with one payload."""
74
+ return run_validators_entrypoint_subprocess(["--pre-tool-use"], stdin_text=json.dumps(payload))
75
+
76
+
77
+ def update_patch_command(relative_path: str, added_line: str) -> str:
78
+ """Build an Update File patch rewriting the module's return line."""
79
+ return UPDATE_PATCH_TEMPLATE.format(
80
+ relative_path=relative_path,
81
+ removed_line=CLEAN_RETURN_LINE,
82
+ added_line=added_line,
83
+ )
84
+
85
+
86
+ def write_clean_module(target_directory: Path, module_name: str) -> Path:
87
+ """Write the clean Python module the payloads target."""
88
+ target_file = target_directory / module_name
89
+ target_file.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
90
+ return target_file
91
+
92
+
93
+ class TestMutationToolRegistration:
94
+ def test_validators_gate_is_registered_on_every_mutation_tool(self) -> None:
95
+ hooks_document = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
96
+ all_matchers = [
97
+ each_group["matcher"]
98
+ for each_group in hooks_document["hooks"]["PreToolUse"]
99
+ for each_hook in each_group.get("hooks", [])
100
+ if VALIDATORS_RUNNER_COMMAND_MARKER in each_hook.get("command", "")
101
+ ]
102
+ assert len(all_matchers) == 1, all_matchers
103
+ assert (
104
+ set(all_matchers[0].split(MATCHER_TOOL_NAME_SEPARATOR))
105
+ == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
106
+ )
107
+
108
+
109
+ class TestMultiEditPayloadLane:
110
+ def test_multi_edit_introducing_a_violation_denies(self, tmp_path: Path) -> None:
111
+ target_file = write_clean_module(tmp_path, "legacy_module.py")
112
+ completed = run_gate(
113
+ {
114
+ "tool_name": "MultiEdit",
115
+ "tool_input": {
116
+ "file_path": str(target_file),
117
+ "edits": [
118
+ {
119
+ "old_string": CLEAN_RETURN_LINE,
120
+ "new_string": MAGIC_VALUE_RETURN_LINE,
121
+ }
122
+ ],
123
+ },
124
+ }
125
+ )
126
+ assert completed.returncode == 0, completed.stderr
127
+ assert DENY_DECISION_FRAGMENT in completed.stdout
128
+ assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
129
+
130
+ def test_multi_edit_leaving_clean_content_allows(self, tmp_path: Path) -> None:
131
+ target_file = write_clean_module(tmp_path, "clean_module.py")
132
+ completed = run_gate(
133
+ {
134
+ "tool_name": "MultiEdit",
135
+ "tool_input": {
136
+ "file_path": str(target_file),
137
+ "edits": [
138
+ {
139
+ "old_string": CLEAN_RETURN_LINE,
140
+ "new_string": CLEAN_REWRITTEN_RETURN_LINE,
141
+ }
142
+ ],
143
+ },
144
+ }
145
+ )
146
+ assert completed.returncode == 0, completed.stderr
147
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
148
+
149
+
150
+ class TestApplyPatchPayloadLane:
151
+ def test_apply_patch_introducing_a_violation_denies(self, tmp_path: Path) -> None:
152
+ write_clean_module(tmp_path, "legacy_module.py")
153
+ completed = run_gate(
154
+ {
155
+ "tool_name": "apply_patch",
156
+ "tool_input": {
157
+ "command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
158
+ },
159
+ "cwd": str(tmp_path),
160
+ }
161
+ )
162
+ assert completed.returncode == 0, completed.stderr
163
+ assert DENY_DECISION_FRAGMENT in completed.stdout
164
+ assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
165
+
166
+ def test_apply_patch_deny_reason_names_the_patched_file(self, tmp_path: Path) -> None:
167
+ target_file = write_clean_module(tmp_path, "legacy_module.py")
168
+ completed = run_gate(
169
+ {
170
+ "tool_name": "apply_patch",
171
+ "tool_input": {
172
+ "command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
173
+ },
174
+ "cwd": str(tmp_path),
175
+ }
176
+ )
177
+ assert completed.returncode == 0, completed.stderr
178
+ assert target_file.name in completed.stdout
179
+
180
+ def test_apply_patch_leaving_clean_content_allows(self, tmp_path: Path) -> None:
181
+ write_clean_module(tmp_path, "clean_module.py")
182
+ completed = run_gate(
183
+ {
184
+ "tool_name": "apply_patch",
185
+ "tool_input": {
186
+ "command": update_patch_command("clean_module.py", CLEAN_REWRITTEN_RETURN_LINE)
187
+ },
188
+ "cwd": str(tmp_path),
189
+ }
190
+ )
191
+ assert completed.returncode == 0, completed.stderr
192
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
193
+
194
+ def test_apply_patch_adding_a_violating_file_denies(self, tmp_path: Path) -> None:
195
+ completed = run_gate(
196
+ {
197
+ "tool_name": "apply_patch",
198
+ "tool_input": {"command": ADD_VIOLATING_FILE_PATCH_COMMAND},
199
+ "cwd": str(tmp_path),
200
+ }
201
+ )
202
+ assert completed.returncode == 0, completed.stderr
203
+ assert DENY_DECISION_FRAGMENT in completed.stdout
204
+ assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
205
+
206
+ def test_apply_patch_deleting_a_file_allows(self, tmp_path: Path) -> None:
207
+ write_clean_module(tmp_path, "legacy_module.py")
208
+ completed = run_gate(
209
+ {
210
+ "tool_name": "apply_patch",
211
+ "tool_input": {"command": DELETE_FILE_PATCH_COMMAND},
212
+ "cwd": str(tmp_path),
213
+ }
214
+ )
215
+ assert completed.returncode == 0, completed.stderr
216
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
217
+
218
+ def test_unparseable_apply_patch_command_allows(self, tmp_path: Path) -> None:
219
+ """A patch this lane cannot read is the enforcer's to deny, not this gate's.
220
+
221
+ ``code_rules_enforcer`` denies a payload whose patch markers it cannot
222
+ parse. Both hooks run on the same call and any denial wins, so this lane
223
+ allows rather than raising a second denial for one cause. Moving that
224
+ denial out of the enforcer leaves a malformed patch with no reader.
225
+ """
226
+ completed = run_gate(
227
+ {
228
+ "tool_name": "apply_patch",
229
+ "tool_input": {"command": UNPARSEABLE_PATCH_COMMAND},
230
+ "cwd": str(tmp_path),
231
+ }
232
+ )
233
+ assert completed.returncode == 0, completed.stderr
234
+ assert DENY_DECISION_FRAGMENT not in completed.stdout
235
+
236
+ def test_apply_patch_denies_a_violation_in_its_second_file(self, tmp_path: Path) -> None:
237
+ write_clean_module(tmp_path, "first_module.py")
238
+ second_file = tmp_path / "second_module.py"
239
+ second_file.write_text(CLEAN_SECOND_MODULE_SOURCE, encoding="utf-8")
240
+ completed = run_gate(
241
+ {
242
+ "tool_name": "apply_patch",
243
+ "tool_input": {"command": TWO_FILE_PATCH_COMMAND},
244
+ "cwd": str(tmp_path),
245
+ }
246
+ )
247
+ assert completed.returncode == 0, completed.stderr
248
+ assert DENY_DECISION_FRAGMENT in completed.stdout
249
+ assert second_file.name in completed.stdout
@@ -0,0 +1,66 @@
1
+ """Tests for the run report run_all_validators renders after a full run.
2
+
3
+ Timing metrics and the version header are the two pieces of that report. They
4
+ moved out of test_run_all_validators.py so the paired file keeps room for the
5
+ gate behavior it exists to pin.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from .run_all_validators import (
11
+ add_timing,
12
+ build_json_output,
13
+ create_timing_metrics,
14
+ format_timing_report,
15
+ print_header,
16
+ )
17
+
18
+
19
+ class TestTimingMetrics:
20
+ def test_create_timing_metrics_empty(self) -> None:
21
+ metrics = create_timing_metrics({})
22
+ assert metrics.total_seconds == 0.0
23
+ assert metrics.validator_times == {}
24
+
25
+ def test_create_timing_metrics_with_data(self) -> None:
26
+ timings = {"Validator A": 1.5, "Validator B": 2.0}
27
+ metrics = create_timing_metrics(timings)
28
+ assert metrics.total_seconds == 3.5
29
+ assert metrics.validator_times == timings
30
+
31
+ def test_add_timing_returns_new_instance(self) -> None:
32
+ metrics1 = create_timing_metrics({})
33
+ metrics2 = add_timing(metrics1, "Test", 1.5)
34
+
35
+ assert metrics1.total_seconds == 0.0
36
+ assert metrics2.total_seconds == 1.5
37
+ assert "Test" not in metrics1.validator_times
38
+ assert metrics2.validator_times["Test"] == 1.5
39
+
40
+ def test_format_report_includes_all_timings(self) -> None:
41
+ metrics = create_timing_metrics({"Fast": 0.1, "Slow": 2.5})
42
+ report = format_timing_report(metrics)
43
+
44
+ assert "Fast" in report
45
+ assert "Slow" in report
46
+ assert "2.6" in report
47
+
48
+
49
+ class TestVersionHeader:
50
+ def test_print_header_includes_version(self, capsys: pytest.CaptureFixture[str]) -> None:
51
+ print_header()
52
+ captured = capsys.readouterr()
53
+
54
+ assert "PRE-PUSH VALIDATOR RESULTS" in captured.out
55
+ assert "(v" in captured.out
56
+
57
+ def test_build_json_output_includes_version(self) -> None:
58
+ json_output = build_json_output(
59
+ results=[],
60
+ metrics=create_timing_metrics({}),
61
+ include_timing=False,
62
+ )
63
+
64
+ assert "version" in json_output
65
+ assert "timestamp" in json_output
66
+ assert isinstance(json_output["version"], str)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.31.1",
3
+ "version": "2.32.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,4 +18,4 @@ When a script file's literal body needs `$(...)`, author it with the Write tool,
18
18
 
19
19
  ## Enforcement
20
20
 
21
- `shell_substitution_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) denies a command carrying a live substitution and returns the split-into-two-calls rewrite. Single-quoted runs are stripped before the scan, and a backtick preceded by an odd number of backslashes is escaped, so an inert mention passes.
21
+ `shell_substitution_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) denies a command carrying a live substitution and returns the split-into-two-calls rewrite. Single-quoted runs are stripped before the scan, and a backtick preceded by an odd number of backslashes is escaped, so an inert mention passes. A quoted heredoc body is dropped for the same reason: the opener forms that quote or escape the delimiter tell bash to expand nothing down to the terminator, so a backtick there is text the file receives. A bare delimiter expands its body and keeps its scan.
package/rules/testing.md CHANGED
@@ -26,3 +26,7 @@ Tests exercise real behavior, real data, and production code paths. A test that
26
26
  ## The File-Level TDD Gate Reads Content
27
27
 
28
28
  `tdd_enforcer.py` records each candidate test's content hash at every sighting and compares that hash on the next write. A first sighting requires content that differs from HEAD.
29
+
30
+ ## The Gate Reads a Recorded Failing Run First
31
+
32
+ `hooks/observability/test_failure_recorder.py` runs on every Bash call and records a single unchained pytest run that names a real test file path and reports a failing exit status. It stores the command, that exit status, and the path in the same content-hash store the gate reads. The gate consults that record before its freshness fallback, and honours it while the candidate's content still matches what failed. `content_hash_store.py`'s module docstring holds the full contract.
@@ -8,6 +8,15 @@ here rather than inline in the modules.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ # The installed tree's own shape is shared with every other reader of it.
12
+ from dev_env_scripts_constants.shared_tree_constants import ( # noqa: F401
13
+ CLAUDE_CONFIG_DIR_ENV_VAR,
14
+ PROCESS_TREE_DIRECTORY_NAME,
15
+ PROCESS_TREE_KILL_MODULE_FILENAME,
16
+ SCRIPTS_DIRECTORY_NAME,
17
+ SHARED_PACKAGE_DIRECTORY_NAME,
18
+ )
19
+
11
20
  GROK_BINARY_NAME: str = "grok"
12
21
  """Executable name resolved on PATH for the headless grok tier."""
13
22
 
@@ -56,19 +65,14 @@ DEFAULT_PING_TIMEOUT_SECONDS: int = 120
56
65
  CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
57
66
  """Per-user directory under the home directory that holds Claude config."""
58
67
 
59
- CLAUDE_CONFIG_DIR_ENV_VAR: str = "CLAUDE_CONFIG_DIR"
60
68
  """Environment variable naming the managed Claude config root."""
61
69
 
62
- PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
63
70
  """Filename of the shared process-tree kill helper."""
64
71
 
65
- SHARED_PACKAGE_DIRECTORY_NAME: str = "_shared"
66
72
  """Directory name of the shared install tree under the managed root."""
67
73
 
68
- PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
69
74
  """Directory name of the process-tree helper package under ``_shared``."""
70
75
 
71
- SCRIPTS_DIRECTORY_NAME: str = "scripts"
72
76
  """Directory name holding Python helpers under a process-tree package."""
73
77
 
74
78
  MANIFEST_FILENAME: str = ".claude-dev-env-manifest.json"
@@ -0,0 +1,17 @@
1
+ """Constants for resolving directories inside the installed shared tree.
2
+
3
+ These name the shape of the installed tree itself, not any one worker or
4
+ skill that reads it, so every caller of shared_tree_paths shares them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ CLAUDE_CONFIG_DIR_ENV_VAR: str = "CLAUDE_CONFIG_DIR"
10
+
11
+ SHARED_PACKAGE_DIRECTORY_NAME: str = "_shared"
12
+
13
+ SCRIPTS_DIRECTORY_NAME: str = "scripts"
14
+
15
+ PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
16
+
17
+ PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
@@ -1,11 +1,16 @@
1
- """Resolve directories inside the installed shared tree."""
1
+ """Resolve a scripts directory inside the installed shared tree.
2
+
3
+ An install can place a managed directory behind a directory junction. Path
4
+ resolution walks that link, so a parent computed from the resolved path
5
+ names the link target's parent, where the shared tree does not sit.
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
5
10
  from collections.abc import Mapping
6
11
  from pathlib import Path
7
12
 
8
- from dev_env_scripts_constants.grok_worker_constants import (
13
+ from dev_env_scripts_constants.shared_tree_constants import (
9
14
  CLAUDE_CONFIG_DIR_ENV_VAR,
10
15
  PROCESS_TREE_DIRECTORY_NAME,
11
16
  PROCESS_TREE_KILL_MODULE_FILENAME,
@@ -14,57 +19,86 @@ from dev_env_scripts_constants.grok_worker_constants import (
14
19
  )
15
20
 
16
21
 
17
- def _process_tree_scripts_directory(from_root: Path) -> Path:
22
+ def _shared_scripts_directory(from_root: Path, shared_subpackage_name: str) -> Path:
23
+ """Return the scripts directory one named sub-package holds under a root."""
18
24
  return (
19
25
  from_root
20
26
  / SHARED_PACKAGE_DIRECTORY_NAME
21
- / PROCESS_TREE_DIRECTORY_NAME
27
+ / shared_subpackage_name
22
28
  / SCRIPTS_DIRECTORY_NAME
23
29
  )
24
30
 
25
31
 
26
- def _has_process_tree_kill_module(from_directory: Path) -> bool:
27
- return (from_directory / PROCESS_TREE_KILL_MODULE_FILENAME).is_file()
28
-
29
-
30
- def resolve_shared_process_tree_scripts_directory(
32
+ def resolve_shared_scripts_directory(
31
33
  module_file: str | Path,
32
34
  all_environment: Mapping[str, str],
35
+ shared_subpackage_name: str,
36
+ marker_filename: str,
37
+ anchor_depth: int,
33
38
  ) -> Path:
34
- """Return the first process-tree scripts directory that holds the kill helper.
39
+ """Return the first shared scripts directory that holds the marker file.
35
40
 
36
41
  ::
37
42
 
38
- scripts dir is a junction; the kill helper sits beside the un-resolved parent
39
- ok: Path(module_file).absolute().parent.parent then the _shared scripts dir
40
- flag: Path(module_file).resolve().parents[1] walks the junction and misses it
43
+ the managed scripts directory is a junction
44
+ ok: an un-resolved parent keeps the link side, where _shared sits
45
+ flag: a resolved parent walks the link and misses _shared entirely
41
46
 
42
- Tries the un-resolved parent first, then the configured managed root, then
43
- the resolved parent. When none hold the helper, return the un-resolved
44
- candidate so import failure names a readable path.
47
+ Tries the un-resolved parent first, then the configured managed root,
48
+ then the resolved parent. When none holds the marker, returns the
49
+ un-resolved candidate, so an import failure names a readable path.
45
50
 
46
51
  Args:
47
52
  module_file: Path of the importing module (``__file__``).
48
53
  all_environment: Mapping that may hold ``CLAUDE_CONFIG_DIR``.
54
+ shared_subpackage_name: Directory under ``_shared`` to resolve.
55
+ marker_filename: File whose presence marks the right directory.
56
+ anchor_depth: Parent levels above the module to treat as the root.
49
57
 
50
58
  Returns:
51
- Directory that should hold ``process_tree_kill.py``.
59
+ Directory that should hold the marker file.
52
60
  """
53
- un_resolved_candidate = _process_tree_scripts_directory(
54
- Path(module_file).absolute().parent.parent
61
+ module_path = Path(module_file)
62
+ un_resolved_candidate = _shared_scripts_directory(
63
+ module_path.absolute().parents[anchor_depth], shared_subpackage_name
55
64
  )
56
65
  all_candidates = [un_resolved_candidate]
57
- configured_root_text = all_environment.get(
58
- CLAUDE_CONFIG_DIR_ENV_VAR, ""
59
- ).strip()
66
+ configured_root_text = all_environment.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
60
67
  if configured_root_text:
61
68
  all_candidates.append(
62
- _process_tree_scripts_directory(Path(configured_root_text))
69
+ _shared_scripts_directory(Path(configured_root_text), shared_subpackage_name)
63
70
  )
64
71
  all_candidates.append(
65
- _process_tree_scripts_directory(Path(module_file).resolve().parents[1])
72
+ _shared_scripts_directory(
73
+ module_path.resolve().parents[anchor_depth], shared_subpackage_name
74
+ )
66
75
  )
67
76
  for each_candidate in all_candidates:
68
- if _has_process_tree_kill_module(each_candidate):
77
+ if (each_candidate / marker_filename).is_file():
69
78
  return each_candidate
70
79
  return un_resolved_candidate
80
+
81
+
82
+ def resolve_shared_process_tree_scripts_directory(
83
+ module_file: str | Path,
84
+ all_environment: Mapping[str, str],
85
+ ) -> Path:
86
+ """Return the process-tree scripts directory that holds the kill helper.
87
+
88
+ Names the process-tree sub-package for a caller sitting one level under
89
+ the installed root, such as a script in the managed ``scripts`` directory.
90
+
91
+ Args:
92
+ module_file: Path of the importing module (``__file__``).
93
+ all_environment: Mapping that may hold ``CLAUDE_CONFIG_DIR``.
94
+
95
+ Returns:
96
+ Directory that should hold ``process_tree_kill.py``.
97
+ """
98
+ return resolve_shared_scripts_directory(
99
+ module_file,
100
+ all_environment,
101
+ PROCESS_TREE_DIRECTORY_NAME,
102
+ PROCESS_TREE_KILL_MODULE_FILENAME,
103
+ 1,
104
+ )
@@ -141,3 +141,33 @@ def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
141
141
  assert not (
142
142
  expected_scripts_directory / PROCESS_TREE_KILL_MODULE_FILENAME
143
143
  ).is_file()
144
+
145
+
146
+ def test_generalized_resolver_serves_a_deeper_anchor(tmp_path: Path) -> None:
147
+ """A caller three levels under the root resolves its own sub-package.
148
+
149
+ This is the shape the pr-loop and codex-review scripts carry, which the
150
+ process-tree wrapper's own anchor of one level cannot reach.
151
+ """
152
+ installed_root = tmp_path / "root"
153
+ module_directory = installed_root / "_shared" / "pr-loop" / "scripts"
154
+ module_directory.mkdir(parents=True)
155
+ module_file = str(module_directory / "run_codex_review.py")
156
+ expected_scripts_directory = (
157
+ installed_root
158
+ / SHARED_PACKAGE_DIRECTORY_NAME
159
+ / PROCESS_TREE_DIRECTORY_NAME
160
+ / SCRIPTS_DIRECTORY_NAME
161
+ )
162
+ expected_scripts_directory.mkdir(parents=True)
163
+ (expected_scripts_directory / PROCESS_TREE_KILL_MODULE_FILENAME).write_text(
164
+ "", encoding="utf-8"
165
+ )
166
+ resolved_scripts_directory = shared_tree_paths.resolve_shared_scripts_directory(
167
+ module_file,
168
+ {},
169
+ PROCESS_TREE_DIRECTORY_NAME,
170
+ PROCESS_TREE_KILL_MODULE_FILENAME,
171
+ 3,
172
+ )
173
+ assert resolved_scripts_directory == expected_scripts_directory