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.
Files changed (128) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/.agents/skills/e-simplify/SKILL.md +20 -8
  3. package/AGENTS.md +6 -0
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  5. package/bin/install.prune.test.mjs +4 -2
  6. package/docs/CODE_RULES.md +11 -0
  7. package/hooks/advisory/migration_safety_advisor.py +25 -1
  8. package/hooks/advisory/refactor_guard.py +85 -18
  9. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  10. package/hooks/advisory/test_refactor_guard.py +87 -0
  11. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  12. package/hooks/blocking/code_rules_constants_config.py +7 -6
  13. package/hooks/blocking/code_rules_enforcer.py +59 -36
  14. package/hooks/blocking/codex_apply_patch.py +136 -21
  15. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  16. package/hooks/blocking/conftest.py +70 -0
  17. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  18. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  19. package/hooks/blocking/pii_payload_scan.py +61 -0
  20. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  21. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  23. package/hooks/blocking/sensitive_file_protector.py +18 -3
  24. package/hooks/blocking/shell_substitution_blocker.py +62 -8
  25. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  26. package/hooks/blocking/state_description_blocker.py +39 -26
  27. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  28. package/hooks/blocking/tdd_enforcer.py +57 -14
  29. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  30. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  31. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  32. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  33. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  37. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
  38. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  39. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  40. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  41. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  42. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  43. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  46. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  49. package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
  50. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  51. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  52. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  53. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  54. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  55. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  56. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  57. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  58. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  59. package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
  60. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  61. package/hooks/blocking/test_state_description_blocker.py +68 -0
  62. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  63. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  64. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  65. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  66. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  67. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  68. package/hooks/conftest.py +47 -0
  69. package/hooks/hooks.json +8 -13
  70. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  71. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
  72. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  73. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  74. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  75. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  76. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  77. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  78. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  79. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  80. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
  81. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  82. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  83. package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/session/task_tool_prompt.py +37 -0
  96. package/hooks/session/test_task_tool_prompt.py +65 -0
  97. package/hooks/validation/hook_format_validator.py +23 -9
  98. package/hooks/validation/test_hook_format_validator.py +58 -0
  99. package/hooks/validators/README.md +1 -1
  100. package/hooks/validators/abbreviation_checks.py +3 -5
  101. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  102. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  103. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  104. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  105. package/hooks/validators/fast_save_validators.py +332 -0
  106. package/hooks/validators/hook_timing_harness.py +8 -4
  107. package/hooks/validators/python_style_checks.py +34 -60
  108. package/hooks/validators/python_style_helpers.py +16 -2
  109. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  110. package/hooks/validators/run_all_validators.py +42 -41
  111. package/hooks/validators/test_abbreviation_checks.py +20 -0
  112. package/hooks/validators/test_fast_save_validators.py +172 -0
  113. package/hooks/validators/test_python_style_checks.py +9 -0
  114. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  115. package/hooks/validators/test_python_style_helpers.py +35 -0
  116. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  117. package/hooks/validators/test_run_all_validators.py +46 -0
  118. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  119. package/package.json +1 -1
  120. package/rules/docstring-prose-matches-implementation.md +1 -1
  121. package/rules/git-workflow.md +2 -0
  122. package/rules/testing.md +4 -0
  123. package/scripts/codex_compat_materializer.py +5 -0
  124. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  125. package/hooks/blocking/tests/conftest.py +0 -10
  126. package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
  127. package/hooks/session/task_list_loop_starter.py +0 -45
  128. 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
- WRITE_TOOL_NAME: str = "Write"
4
- EDIT_TOOL_NAME: str = "Edit"
5
- MULTI_EDIT_TOOL_NAME: str = "MultiEdit"
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 MultiEdit.
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
- try:
216
- resolved_file_path = str(Path(file_path).resolve())
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
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
13
- if _hooks_dir not in sys.path:
14
- sys.path.insert(0, _hooks_dir)
15
-
16
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
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 = tool_input.get("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: