claude-dev-env 2.30.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/pr-description-writer.md +1 -1
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +59 -36
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +62 -8
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tests for subprocess_budget_completeness_content.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
10
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
12
|
+
|
|
13
|
+
from hooks_constants.subprocess_budget_completeness_content import (
|
|
14
|
+
reconstructed_edit_content,
|
|
15
|
+
reconstructed_multi_edit_content,
|
|
16
|
+
resolved_content,
|
|
17
|
+
)
|
|
18
|
+
except ImportError as import_error:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"test_subprocess_budget_completeness_content: cannot import its sibling modules; "
|
|
21
|
+
"ensure the hooks directory is importable."
|
|
22
|
+
) from import_error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resolved_content_returns_write_content_directly() -> None:
|
|
26
|
+
assert resolved_content({"content": "abc"}) == "abc"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_reconstructed_edit_content_replaces_the_old_string(tmp_path: pathlib.Path) -> None:
|
|
30
|
+
target_file = tmp_path / "module.py"
|
|
31
|
+
target_file.write_text("value = 1\n", encoding="utf-8")
|
|
32
|
+
reconstructed = reconstructed_edit_content(
|
|
33
|
+
{"file_path": str(target_file), "old_string": "value = 1", "new_string": "value = 2"}
|
|
34
|
+
)
|
|
35
|
+
assert reconstructed == "value = 2\n"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_reconstructed_multi_edit_content_applies_every_edit_in_order(
|
|
39
|
+
tmp_path: pathlib.Path,
|
|
40
|
+
) -> None:
|
|
41
|
+
target_file = tmp_path / "module.py"
|
|
42
|
+
target_file.write_text("a = 1\nb = 2\n", encoding="utf-8")
|
|
43
|
+
all_edits = [
|
|
44
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
45
|
+
{"old_string": "b = 2", "new_string": "b = 22"},
|
|
46
|
+
]
|
|
47
|
+
reconstructed = reconstructed_multi_edit_content({"file_path": str(target_file)}, all_edits)
|
|
48
|
+
assert reconstructed == "a = 11\nb = 22\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_resolved_content_dispatches_a_multi_edit_payload(tmp_path: pathlib.Path) -> None:
|
|
52
|
+
target_file = tmp_path / "module.py"
|
|
53
|
+
target_file.write_text("a = 1\n", encoding="utf-8")
|
|
54
|
+
reconstructed = resolved_content(
|
|
55
|
+
{
|
|
56
|
+
"file_path": str(target_file),
|
|
57
|
+
"edits": [{"old_string": "a = 1", "new_string": "a = 11"}],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
assert reconstructed == "a = 11\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_resolved_content_returns_empty_for_an_unreadable_edit_target(
|
|
64
|
+
tmp_path: pathlib.Path,
|
|
65
|
+
) -> None:
|
|
66
|
+
missing_target = tmp_path / "missing.py"
|
|
67
|
+
assert (
|
|
68
|
+
resolved_content({"file_path": str(missing_target), "old_string": "a", "new_string": "b"})
|
|
69
|
+
== ""
|
|
70
|
+
)
|
|
@@ -4,6 +4,8 @@ The edit lane and the full gate share every check except six that read
|
|
|
4
4
|
sibling modules from disk. These names identify each phase, name the checks
|
|
5
5
|
the full gate alone runs, name the hook-infrastructure checks the edit lane
|
|
6
6
|
still runs, and template the error for an unrecognized phase.
|
|
7
|
+
|
|
8
|
+
Scope assigns the lane. See ``docs/CODE_RULES.md`` section 11.6.
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
EDIT_LANE_PHASE: str = "edit_lane"
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Configuration constants for the workflow_substitution_slot_blocker PreToolUse hook."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
# The dispatcher constants module is the single source for tool names.
|
|
4
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
5
|
+
EDIT_TOOL_NAME,
|
|
6
|
+
MULTI_EDIT_TOOL_NAME,
|
|
7
|
+
WRITE_TOOL_NAME,
|
|
8
|
+
)
|
|
9
|
+
|
|
6
10
|
|
|
7
11
|
WORKFLOW_FILE_SUFFIX: str = ".workflow.js"
|
|
8
12
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook: record that this session spawned the pr-description-writer agent.
|
|
3
|
+
|
|
4
|
+
Picture opening a pull request whose body nobody wrote from the diff — it reads
|
|
5
|
+
like a commit message, and the reviewer learns nothing before opening the files.
|
|
6
|
+
The ``pr-description-writer`` agent exists to write that body, and it hands the
|
|
7
|
+
markdown and a body-file path back to the caller to publish.
|
|
8
|
+
|
|
9
|
+
This hook remembers that the agent ran. After each Agent or Task spawn naming
|
|
10
|
+
``pr-description-writer``, it touches an empty marker file for this session in
|
|
11
|
+
the system temp directory. The ``gh pr create`` gate reads that marker.
|
|
12
|
+
|
|
13
|
+
The marker carries one fact — the agent ran this session — so a touch records
|
|
14
|
+
everything the gate needs, and two parallel spawns racing on the same touch
|
|
15
|
+
leave the same result. The file name carries the session id, so a marker a
|
|
16
|
+
crashed session leaves behind is inert: no later session reads it.
|
|
17
|
+
|
|
18
|
+
The hook never blocks a tool call. A tool that is not a spawn, a spawn of
|
|
19
|
+
another agent, a malformed payload, a payload naming no session, or a failed
|
|
20
|
+
touch each returns quietly.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
30
|
+
if _hooks_dir not in sys.path:
|
|
31
|
+
sys.path.insert(0, _hooks_dir)
|
|
32
|
+
|
|
33
|
+
from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
34
|
+
ALL_AGENT_SPAWN_TOOL_NAMES,
|
|
35
|
+
PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
|
|
36
|
+
SPAWN_MARKER_FILE_PREFIX,
|
|
37
|
+
SPAWN_MARKER_FILE_SUFFIX,
|
|
38
|
+
SUBAGENT_TYPE_FIELD_NAME,
|
|
39
|
+
TOOL_INPUT_FIELD_NAME,
|
|
40
|
+
TOOL_NAME_FIELD_NAME,
|
|
41
|
+
)
|
|
42
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
43
|
+
read_hook_input_dictionary_from_stdin,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
46
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
|
|
51
|
+
"""Return the marker path for one session, or None without a usable id.
|
|
52
|
+
|
|
53
|
+
::
|
|
54
|
+
|
|
55
|
+
session_id = "abc-123" -> <temp>/claude-session-pr-description-writer-abc-123.marker
|
|
56
|
+
session_id = "" -> None
|
|
57
|
+
session_id = "///" -> None
|
|
58
|
+
|
|
59
|
+
Unsafe characters are stripped so the name stays anchored inside the temp
|
|
60
|
+
directory. A session id that sanitizes to nothing yields no path, so every
|
|
61
|
+
marker on disk belongs to one real session and a marker a crashed session
|
|
62
|
+
leaves behind is inert — no later session reads it.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
66
|
+
temp_directory: The directory holding the marker. Defaults to the
|
|
67
|
+
system temp directory.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Absolute path to this session's marker file, or None when the payload
|
|
71
|
+
names no session this hook can key on.
|
|
72
|
+
"""
|
|
73
|
+
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
|
|
74
|
+
if not sanitized_session_id:
|
|
75
|
+
return None
|
|
76
|
+
file_name = f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
|
|
77
|
+
resolved_temp_directory = temp_directory or Path(tempfile.gettempdir())
|
|
78
|
+
return resolved_temp_directory / file_name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _record_spawn(session_id: str) -> None:
|
|
82
|
+
"""Touch this session's marker file, ignoring a write failure.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
86
|
+
"""
|
|
87
|
+
marker_file = spawn_marker_path(session_id)
|
|
88
|
+
if marker_file is None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
marker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
marker_file.touch(exist_ok=True)
|
|
93
|
+
except OSError:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
"""Record the marker when this payload is a pr-description-writer spawn.
|
|
99
|
+
|
|
100
|
+
Reads the PostToolUse payload from stdin and touches this session's marker
|
|
101
|
+
file when an Agent or Task spawn names the ``pr-description-writer`` agent.
|
|
102
|
+
Returns on every other branch — another tool, another agent, a malformed
|
|
103
|
+
payload, or a missing ``tool_input`` — so the spawn is never blocked.
|
|
104
|
+
"""
|
|
105
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
106
|
+
if hook_payload is None:
|
|
107
|
+
return
|
|
108
|
+
tool_name = hook_payload.get(TOOL_NAME_FIELD_NAME, "")
|
|
109
|
+
if tool_name not in ALL_AGENT_SPAWN_TOOL_NAMES:
|
|
110
|
+
return
|
|
111
|
+
tool_input = hook_payload.get(TOOL_INPUT_FIELD_NAME, {})
|
|
112
|
+
if not isinstance(tool_input, dict):
|
|
113
|
+
return
|
|
114
|
+
if tool_input.get(SUBAGENT_TYPE_FIELD_NAME) != PR_DESCRIPTION_WRITER_SUBAGENT_TYPE:
|
|
115
|
+
return
|
|
116
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
117
|
+
_record_spawn(session_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
|
@@ -31,12 +31,14 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
31
31
|
if _hooks_dir not in sys.path:
|
|
32
32
|
sys.path.insert(0, _hooks_dir)
|
|
33
33
|
|
|
34
|
+
from blocking.codex_apply_patch import payload_patch_target_paths # noqa: E402
|
|
34
35
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
36
|
read_hook_input_dictionary_from_stdin,
|
|
36
37
|
)
|
|
37
38
|
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
38
39
|
ALL_EDITED_FILE_PATHS_KEY,
|
|
39
40
|
ALL_TRACKED_EDIT_TOOL_NAMES,
|
|
41
|
+
APPLY_PATCH_TOOL_NAME,
|
|
40
42
|
LOCK_ACQUIRE_RETRY_SECONDS,
|
|
41
43
|
LOCK_ACQUIRE_TIMEOUT_SECONDS,
|
|
42
44
|
SESSION_EDIT_FILE_PREFIX,
|
|
@@ -49,6 +51,23 @@ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
|
49
51
|
)
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _resolved_path_or_none(file_path: str) -> str | None:
|
|
55
|
+
"""Return the resolved absolute path, or None when resolution fails."""
|
|
56
|
+
try:
|
|
57
|
+
return str(Path(file_path).resolve())
|
|
58
|
+
except OSError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _record_apply_patch_edits(hook_payload: dict, tool_input: dict) -> None:
|
|
63
|
+
"""Record the resolved path of every file a Codex apply_patch payload names."""
|
|
64
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
65
|
+
for each_file_path in payload_patch_target_paths(hook_payload, tool_input):
|
|
66
|
+
resolved_file_path = _resolved_path_or_none(each_file_path)
|
|
67
|
+
if resolved_file_path is not None:
|
|
68
|
+
_record_edited_path(session_id, resolved_file_path)
|
|
69
|
+
|
|
70
|
+
|
|
52
71
|
def _session_edit_file_path(session_id: str) -> Path:
|
|
53
72
|
"""Return the per-session tracker file path in the system temp directory.
|
|
54
73
|
|
|
@@ -193,7 +212,7 @@ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
|
|
|
193
212
|
|
|
194
213
|
|
|
195
214
|
def main() -> None:
|
|
196
|
-
"""Record the edited file path for a Write, Edit, or
|
|
215
|
+
"""Record the edited file path for a Write, Edit, MultiEdit, or apply_patch.
|
|
197
216
|
|
|
198
217
|
Reads the PostToolUse payload from stdin, and for a tracked edit tool
|
|
199
218
|
appends the resolved absolute file path to this session's tracker file.
|
|
@@ -209,12 +228,14 @@ def main() -> None:
|
|
|
209
228
|
tool_input = hook_payload.get("tool_input", {})
|
|
210
229
|
if not isinstance(tool_input, dict):
|
|
211
230
|
return
|
|
231
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
232
|
+
_record_apply_patch_edits(hook_payload, tool_input)
|
|
233
|
+
return
|
|
212
234
|
file_path = tool_input.get("file_path", "")
|
|
213
235
|
if not isinstance(file_path, str) or not file_path:
|
|
214
236
|
return
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
except OSError:
|
|
237
|
+
resolved_file_path = _resolved_path_or_none(file_path)
|
|
238
|
+
if resolved_file_path is None:
|
|
218
239
|
return
|
|
219
240
|
session_id = str(hook_payload.get("session_id") or "")
|
|
220
241
|
_record_edited_path(session_id, resolved_file_path)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Unit tests for the pr-description-writer spawn tracker PostToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_OBSERVABILITY_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
_HOOKS_DIRECTORY = _OBSERVABILITY_DIRECTORY.parent
|
|
16
|
+
for each_directory in (_OBSERVABILITY_DIRECTORY, _HOOKS_DIRECTORY):
|
|
17
|
+
if str(each_directory) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(each_directory))
|
|
19
|
+
|
|
20
|
+
_TRACKER_SPEC = importlib.util.spec_from_file_location(
|
|
21
|
+
"pr_description_writer_spawn_tracker",
|
|
22
|
+
_OBSERVABILITY_DIRECTORY / "pr_description_writer_spawn_tracker.py",
|
|
23
|
+
)
|
|
24
|
+
assert _TRACKER_SPEC is not None
|
|
25
|
+
assert _TRACKER_SPEC.loader is not None
|
|
26
|
+
tracker_module = importlib.util.module_from_spec(_TRACKER_SPEC)
|
|
27
|
+
_TRACKER_SPEC.loader.exec_module(tracker_module)
|
|
28
|
+
|
|
29
|
+
_SESSION_ID = "session-abc123"
|
|
30
|
+
_OTHER_SUBAGENT_TYPE = "general-purpose"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _spawn_payload(
|
|
34
|
+
subagent_type: str,
|
|
35
|
+
tool_name: str = "Agent",
|
|
36
|
+
session_id: str = _SESSION_ID,
|
|
37
|
+
) -> dict[str, object]:
|
|
38
|
+
"""Build a PostToolUse payload for one agent spawn.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
subagent_type: The agent type the spawn names.
|
|
42
|
+
tool_name: The spawning tool name.
|
|
43
|
+
session_id: The session the spawn belongs to.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The payload dictionary the tracker reads from stdin.
|
|
47
|
+
"""
|
|
48
|
+
return {
|
|
49
|
+
"tool_name": tool_name,
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"tool_input": {
|
|
52
|
+
"subagent_type": subagent_type,
|
|
53
|
+
"description": "Write the PR body",
|
|
54
|
+
"prompt": "Write a PR description from the diff.",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_tracker(payload: dict[str, object], temp_directory: Path) -> None:
|
|
60
|
+
"""Run the tracker's main against a payload with temp redirected.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
payload: The PostToolUse payload to feed on stdin.
|
|
64
|
+
temp_directory: The directory standing in for the system temp root.
|
|
65
|
+
"""
|
|
66
|
+
with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
|
|
67
|
+
with mock.patch.object(
|
|
68
|
+
tracker_module.tempfile, "gettempdir", return_value=str(temp_directory)
|
|
69
|
+
):
|
|
70
|
+
tracker_module.main()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def should_record_marker_when_pr_description_writer_spawns(tmp_path: Path) -> None:
|
|
74
|
+
"""A pr-description-writer spawn leaves this session's marker on disk."""
|
|
75
|
+
_run_tracker(_spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE), tmp_path)
|
|
76
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def should_leave_no_marker_when_another_agent_spawns(tmp_path: Path) -> None:
|
|
80
|
+
"""A spawn of a different agent type records nothing."""
|
|
81
|
+
_run_tracker(_spawn_payload(_OTHER_SUBAGENT_TYPE), tmp_path)
|
|
82
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("spawn_tool_name", ["Agent", "Task"])
|
|
86
|
+
def should_record_marker_for_each_spawn_tool_name(spawn_tool_name: str, tmp_path: Path) -> None:
|
|
87
|
+
"""Both spawning tool names record the marker."""
|
|
88
|
+
payload = _spawn_payload(
|
|
89
|
+
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name=spawn_tool_name
|
|
90
|
+
)
|
|
91
|
+
_run_tracker(payload, tmp_path)
|
|
92
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def should_leave_no_marker_for_an_unrelated_tool(tmp_path: Path) -> None:
|
|
96
|
+
"""A tool that is not a spawn records nothing, even naming the agent."""
|
|
97
|
+
payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name="Bash")
|
|
98
|
+
_run_tracker(payload, tmp_path)
|
|
99
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def should_keep_each_session_marker_separate(tmp_path: Path) -> None:
|
|
103
|
+
"""A spawn in one session leaves another session's marker absent."""
|
|
104
|
+
payload = _spawn_payload(
|
|
105
|
+
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, session_id="session-one"
|
|
106
|
+
)
|
|
107
|
+
_run_tracker(payload, tmp_path)
|
|
108
|
+
assert tracker_module.spawn_marker_path("session-one", tmp_path).exists() is True
|
|
109
|
+
assert tracker_module.spawn_marker_path("session-two", tmp_path).exists() is False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def should_record_twice_without_error(tmp_path: Path) -> None:
|
|
113
|
+
"""A second spawn in one session leaves the marker in place."""
|
|
114
|
+
payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE)
|
|
115
|
+
_run_tracker(payload, tmp_path)
|
|
116
|
+
_run_tracker(payload, tmp_path)
|
|
117
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def should_ignore_a_malformed_payload(tmp_path: Path) -> None:
|
|
121
|
+
"""Unparseable stdin records nothing and raises nothing."""
|
|
122
|
+
with mock.patch("sys.stdin", io.StringIO("this is not json")):
|
|
123
|
+
with mock.patch.object(tracker_module.tempfile, "gettempdir", return_value=str(tmp_path)):
|
|
124
|
+
tracker_module.main()
|
|
125
|
+
assert list(tmp_path.iterdir()) == []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def should_ignore_a_payload_with_no_tool_input(tmp_path: Path) -> None:
|
|
129
|
+
"""A spawn payload missing tool_input records nothing."""
|
|
130
|
+
payload: dict[str, object] = {"tool_name": "Agent", "session_id": _SESSION_ID}
|
|
131
|
+
_run_tracker(payload, tmp_path)
|
|
132
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("unusable_session_id", ["", "///"])
|
|
136
|
+
def should_record_nothing_without_a_usable_session_id(
|
|
137
|
+
unusable_session_id: str, tmp_path: Path
|
|
138
|
+
) -> None:
|
|
139
|
+
"""A payload naming no usable session records no marker at all."""
|
|
140
|
+
payload = _spawn_payload(
|
|
141
|
+
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
|
|
142
|
+
session_id=unusable_session_id,
|
|
143
|
+
)
|
|
144
|
+
_run_tracker(payload, tmp_path)
|
|
145
|
+
assert tracker_module.spawn_marker_path(unusable_session_id, tmp_path) is None
|
|
146
|
+
assert list(tmp_path.iterdir()) == []
|
|
@@ -172,3 +172,35 @@ def test_should_exit_zero_on_malformed_stdin(
|
|
|
172
172
|
) -> None:
|
|
173
173
|
_run_main_with_stdin("not valid json {{{")
|
|
174
174
|
assert not any(redirected_temp_directory.iterdir())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_should_record_every_apply_patch_target_file(
|
|
178
|
+
redirected_temp_directory: pathlib.Path,
|
|
179
|
+
) -> None:
|
|
180
|
+
working_directory = redirected_temp_directory / "repo"
|
|
181
|
+
working_directory.mkdir()
|
|
182
|
+
(working_directory / "updated.py").write_text("before\n", encoding="utf-8")
|
|
183
|
+
patch_command = (
|
|
184
|
+
"*** Begin Patch\n"
|
|
185
|
+
"*** Update File: updated.py\n"
|
|
186
|
+
"@@\n"
|
|
187
|
+
"-before\n"
|
|
188
|
+
"+after\n"
|
|
189
|
+
"*** Add File: added.py\n"
|
|
190
|
+
"+new\n"
|
|
191
|
+
"*** End Patch"
|
|
192
|
+
)
|
|
193
|
+
payload = json.dumps(
|
|
194
|
+
{
|
|
195
|
+
"session_id": _SESSION_ID,
|
|
196
|
+
"tool_name": "apply_patch",
|
|
197
|
+
"cwd": str(working_directory),
|
|
198
|
+
"tool_input": {"command": patch_command},
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
_run_main_with_stdin(payload)
|
|
203
|
+
|
|
204
|
+
recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
|
|
205
|
+
assert str((working_directory / "updated.py").resolve()) in recorded
|
|
206
|
+
assert str((working_directory / "added.py").resolve()) in recorded
|
|
@@ -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()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for task_tool_prompt — SessionStart hook that directs task-tool tracking."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
11
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
12
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
13
|
+
if each_sys_path_entry not in sys.path:
|
|
14
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
15
|
+
|
|
16
|
+
prompt = importlib.import_module("task_tool_prompt")
|
|
17
|
+
task_tool_prompt_constants = importlib.import_module("hooks_constants.task_tool_prompt_constants")
|
|
18
|
+
TASK_TOOL_DIRECTIVE = task_tool_prompt_constants.TASK_TOOL_DIRECTIVE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_main() -> str:
|
|
22
|
+
"""Return stdout produced by running the hook's main()."""
|
|
23
|
+
captured_stdout = StringIO()
|
|
24
|
+
with patch("sys.stdout", captured_stdout):
|
|
25
|
+
prompt.main()
|
|
26
|
+
return captured_stdout.getvalue()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestSessionDirective:
|
|
30
|
+
def test_main_emits_additional_context(self) -> None:
|
|
31
|
+
emitted = json.loads(_run_main())
|
|
32
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
33
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
34
|
+
assert "additionalContext" in hook_output
|
|
35
|
+
|
|
36
|
+
def test_directive_names_the_tracking_tool(self) -> None:
|
|
37
|
+
emitted = json.loads(_run_main())
|
|
38
|
+
assert "task or todo tracking tool" in emitted["hookSpecificOutput"]["additionalContext"]
|
|
39
|
+
|
|
40
|
+
def test_directive_names_no_vendor_or_host_product(self) -> None:
|
|
41
|
+
emitted = json.loads(_run_main())
|
|
42
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
43
|
+
for each_forbidden_term in ("Claude", "Cursor", "Codex", "TaskCreate", "TodoWrite"):
|
|
44
|
+
assert each_forbidden_term not in additional_context
|
|
45
|
+
|
|
46
|
+
def test_directive_says_create_tasks_at_session_start(self) -> None:
|
|
47
|
+
emitted = json.loads(_run_main())
|
|
48
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
49
|
+
assert "start of the session" in additional_context
|
|
50
|
+
|
|
51
|
+
def test_directive_says_update_as_work_proceeds(self) -> None:
|
|
52
|
+
emitted = json.loads(_run_main())
|
|
53
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
54
|
+
assert "update" in additional_context
|
|
55
|
+
assert "as work proceeds" in additional_context
|
|
56
|
+
|
|
57
|
+
def test_directive_names_no_loop_cadence(self) -> None:
|
|
58
|
+
emitted = json.loads(_run_main())
|
|
59
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
60
|
+
assert "/loop" not in additional_context
|
|
61
|
+
assert "cadence" not in additional_context
|
|
62
|
+
assert "minute" not in additional_context
|
|
63
|
+
|
|
64
|
+
def test_build_session_directive_returns_the_shared_constant(self) -> None:
|
|
65
|
+
assert prompt.build_session_directive() == TASK_TOOL_DIRECTIVE
|
|
@@ -9,17 +9,34 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
sys.path
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
try:
|
|
13
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _hooks_dir not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_dir)
|
|
16
|
+
|
|
17
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
18
|
+
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
19
|
+
except ImportError as import_error:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"hook_format_validator: cannot import its sibling modules; "
|
|
22
|
+
"ensure the hooks directory is importable."
|
|
23
|
+
) from import_error
|
|
17
24
|
|
|
18
25
|
SIMPLE_PATTERN = re.compile(
|
|
19
26
|
r'python3?\s+~/\.claude/hooks/'
|
|
20
27
|
)
|
|
21
28
|
|
|
22
29
|
|
|
30
|
+
def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
|
|
31
|
+
"""Return the text a Write, Edit, or MultiEdit payload introduces."""
|
|
32
|
+
if tool_name == "MultiEdit":
|
|
33
|
+
return joined_new_strings(all_tool_input)
|
|
34
|
+
content = all_tool_input.get("content", "")
|
|
35
|
+
if not content:
|
|
36
|
+
content = all_tool_input.get("new_string", "")
|
|
37
|
+
return content
|
|
38
|
+
|
|
39
|
+
|
|
23
40
|
def main() -> None:
|
|
24
41
|
try:
|
|
25
42
|
hook_input = json.load(sys.stdin)
|
|
@@ -36,10 +53,7 @@ def main() -> None:
|
|
|
36
53
|
sys.exit(0)
|
|
37
54
|
|
|
38
55
|
tool_name = hook_input.get("tool_name", "")
|
|
39
|
-
content =
|
|
40
|
-
if not content:
|
|
41
|
-
new_string = tool_input.get("new_string", "")
|
|
42
|
-
content = new_string
|
|
56
|
+
content = _resolve_content(tool_name, tool_input)
|
|
43
57
|
|
|
44
58
|
if tool_name == "Write" and content:
|
|
45
59
|
try:
|