claude-dev-env 1.85.0 → 1.86.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.
@@ -0,0 +1,92 @@
1
+ """Constants for the session-edit stage gate hook trio.
2
+
3
+ Shared by the PostToolUse tracker
4
+ (``hooks/observability/session_file_edit_tracker.py``), the PreToolUse gate
5
+ (``hooks/blocking/session_edit_stage_gate.py``), and the SessionStart cleanup
6
+ (``hooks/session/session_edit_tracker_cleanup.py``): the per-session tracker
7
+ filename shape and JSON payload key, the session-id sanitize pattern, the
8
+ SessionStart source key and the fresh-startup source value the cleanup keys its
9
+ deletion on, the edit-tool name set, the per-session lock filename suffix and
10
+ the lock-acquire timing, the git-diff command and its output encoding, the
11
+ commit-flag escapes the gate honors, the git executable token, the staging
12
+ subcommand tokens, the git global value-option tokens, and the stage-all add
13
+ flag and pathspec tokens that mark a pre-commit ``git add``/``git stage``
14
+ segment as staging the commit, and the deny-message template.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ SESSION_EDIT_FILE_PREFIX: str = "claude-session-edits-"
22
+ SESSION_EDIT_FILE_SUFFIX: str = ".json"
23
+ ALL_EDITED_FILE_PATHS_KEY: str = "all_edited_file_paths"
24
+ STATE_FILE_DEFAULT_SESSION_ID: str = "default"
25
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9_-]")
26
+
27
+ SESSION_START_SOURCE_PAYLOAD_KEY: str = "source"
28
+ SESSION_START_SOURCE_FRESH_STARTUP: str = "startup"
29
+
30
+ ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = ("Write", "Edit", "MultiEdit")
31
+
32
+ STATE_FILE_ATOMIC_WRITE_SUFFIX: str = ".tmp"
33
+ STATE_FILE_JSON_INDENT_SPACES: int = 2
34
+
35
+ SESSION_EDIT_LOCK_FILE_SUFFIX: str = ".lock"
36
+ LOCK_ACQUIRE_TIMEOUT_SECONDS: float = 5.0
37
+ LOCK_ACQUIRE_RETRY_SECONDS: float = 0.01
38
+
39
+ GIT_DIFF_TIMEOUT_SECONDS: int = 5
40
+ GIT_DIFF_OUTPUT_ENCODING: str = "utf-8"
41
+ ALL_TRACKED_UNSTAGED_FILES_COMMAND: tuple[str, ...] = (
42
+ "git",
43
+ "-c",
44
+ "core.quotePath=false",
45
+ "diff",
46
+ "--name-only",
47
+ )
48
+
49
+ PARTIAL_COMMIT_BYPASS_MARKER: str = "# partial-commit"
50
+ COMMIT_SUBCOMMAND_TOKEN: str = "commit"
51
+ GIT_EXECUTABLE_TOKEN: str = "git"
52
+ ENV_ASSIGNMENT_PREFIX_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
53
+ ALL_STAGING_SUBCOMMAND_TOKENS: frozenset[str] = frozenset({"add", "stage"})
54
+ ALL_STAGE_ALL_ADD_FLAG_TOKENS: frozenset[str] = frozenset({"-A", "--all", "-u", "--update"})
55
+ ALL_STAGE_ALL_ADD_PATHSPEC_TOKENS: frozenset[str] = frozenset({".", ":/"})
56
+ ALL_GIT_GLOBAL_VALUE_OPTION_TOKENS: frozenset[str] = frozenset(
57
+ {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"}
58
+ )
59
+ SHORT_FLAG_PREFIX: str = "-"
60
+ LONG_FLAG_PREFIX: str = "--"
61
+ COMMIT_ALL_SHORT_FLAG_LETTER: str = "a"
62
+ ALL_COMMIT_VALUE_OPTION_SHORT_LETTERS: frozenset[str] = frozenset({"m", "F", "C", "c"})
63
+ ALL_COMMIT_ALL_FLAGS: frozenset[str] = frozenset({"-a", "--all"})
64
+ ALL_COMMIT_VALUE_OPTION_TOKENS: frozenset[str] = frozenset(
65
+ {
66
+ "-m",
67
+ "--message",
68
+ "-F",
69
+ "--file",
70
+ "-C",
71
+ "--reuse-message",
72
+ "-c",
73
+ "--reedit-message",
74
+ "--author",
75
+ "--date",
76
+ "--fixup",
77
+ "--squash",
78
+ "--cleanup",
79
+ }
80
+ )
81
+ ALL_COMMAND_SEPARATOR_TOKENS: frozenset[str] = frozenset({"&&", "||", ";", "|", "&"})
82
+
83
+ DENY_FILE_BULLET_PREFIX: str = " - "
84
+ DENY_FILE_BULLET_LINE_SEPARATOR: str = "\n"
85
+ DENY_PATHSPEC_SEPARATOR: str = " "
86
+ SESSION_EDIT_DENY_TEMPLATE: str = (
87
+ "BLOCKED: these files were edited this session and are tracked but left "
88
+ "unstaged, so this commit would drop them:\n{file_list}\n"
89
+ "Stage them with `git add -- {space_joined_paths}`, or run `git commit -a` to "
90
+ "include every tracked change, or add `{bypass_marker}` to the command to "
91
+ "commit without them on purpose."
92
+ )
@@ -0,0 +1,18 @@
1
+ """Directive text for the task_list_loop_starter SessionStart hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "TASK_LIST_MAINTENANCE_INSTRUCTION",
7
+ "TASK_LIST_LOOP_DIRECTIVE",
8
+ ]
9
+
10
+ TASK_LIST_MAINTENANCE_INSTRUCTION = "build or update the task list using the task tools"
11
+
12
+ TASK_LIST_LOOP_DIRECTIVE = (
13
+ "TASK LIST MAINTENANCE: Keep this session's task list current on a 10-minute "
14
+ "cadence. If a task-list maintenance loop is not already running this session, "
15
+ "start one now with Claude Code's built-in /loop skill on a 10-minute interval: "
16
+ f'/loop 10m "{TASK_LIST_MAINTENANCE_INSTRUCTION}". Run that instruction once '
17
+ "immediately, then let the loop refresh the list every 10 minutes."
18
+ )
@@ -7,7 +7,7 @@ Hooks that run at session or config-change boundaries rather than on individual
7
7
  | File | Event | What it does |
8
8
  |---|---|---|
9
9
  | `config_change_guard.py` | PostToolUse (Write/Edit on `settings.json`) | Counts hooks in the edited `settings.json` and logs any change to `~/.claude/cache/config-change-audit.log`; alerts when the hook count drops below the last known value |
10
- | `pr_converge_bugteam_skill_tracker.py` | PostToolUse | Tracks which bugteam skill runs have completed within a pr-converge loop, so the enforcer can verify parallel execution |
10
+ | `pr_converge_bugteam_skill_tracker.py` | PreToolUse (Skill) | Tracks which bugteam skill runs have completed within a pr-converge loop, so the enforcer can verify parallel execution |
11
11
  | `session_end_cleanup.py` | SessionEnd | Purges stale cache entries from `~/.claude/cache/` (entries older than the configured threshold) and old backup files |
12
12
  | `test_config_change_guard.py` | — | Tests for `config_change_guard.py` |
13
13
  | `test_pr_converge_bugteam_skill_tracker.py` | — | Tests for `pr_converge_bugteam_skill_tracker.py` |
@@ -7,7 +7,9 @@ PostToolUse hooks that record agent behavior for later review. These hooks do no
7
7
  | File | Event | What it does |
8
8
  |---|---|---|
9
9
  | `instructions_loaded_logger.py` | PostToolUse (file load events) | Appends a JSONL record to `~/.claude/logs/instructions_loaded.jsonl` each time Claude Code loads a context file (CLAUDE.md, rules, skills), capturing the file path, load reason, memory type, and session ID |
10
+ | `session_file_edit_tracker.py` | PostToolUse (Write/Edit/MultiEdit) | Records the resolved absolute path of each file edited this session into a per-session JSON file under the system temp directory, for the session edit stage gate to read at commit time |
10
11
  | `test_instructions_loaded_logger.py` | — | Tests for `instructions_loaded_logger.py` |
12
+ | `test_session_file_edit_tracker.py` | — | Tests for `session_file_edit_tracker.py` |
11
13
 
12
14
  ## Conventions
13
15
 
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook: record files edited this session for the stage gate.
3
+
4
+ Picture finishing a change, running `git commit`, and later finding one file
5
+ never made it in — it was edited but never staged, and the commit dropped it
6
+ silently. This hook prevents that by remembering every file the session
7
+ touches. After each Write, Edit, or MultiEdit it appends the edited file's
8
+ resolved absolute path to a per-session JSON file in the system temp directory,
9
+ which the stage gate reads at commit time. Concurrent invocations — the case
10
+ parallel Write or Edit calls in one turn produce — serialize on a best-effort
11
+ per-session lock file. When the lock cannot be acquired within the timeout, the
12
+ write proceeds without it rather than stalling the edit that triggered the hook.
13
+
14
+ The hook never blocks a tool call. A non-edit tool, a malformed payload, a
15
+ missing file path, or a failed write each returns quietly, so a logging problem
16
+ never interrupts the edit that triggered it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import json
23
+ import os
24
+ import sys
25
+ import tempfile
26
+ import time
27
+ from collections.abc import Iterator
28
+ from pathlib import Path
29
+
30
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
31
+ if _hooks_dir not in sys.path:
32
+ sys.path.insert(0, _hooks_dir)
33
+
34
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
35
+ read_hook_input_dictionary_from_stdin,
36
+ )
37
+ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
38
+ ALL_EDITED_FILE_PATHS_KEY,
39
+ ALL_TRACKED_EDIT_TOOL_NAMES,
40
+ LOCK_ACQUIRE_RETRY_SECONDS,
41
+ LOCK_ACQUIRE_TIMEOUT_SECONDS,
42
+ SESSION_EDIT_FILE_PREFIX,
43
+ SESSION_EDIT_FILE_SUFFIX,
44
+ SESSION_EDIT_LOCK_FILE_SUFFIX,
45
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
46
+ STATE_FILE_ATOMIC_WRITE_SUFFIX,
47
+ STATE_FILE_DEFAULT_SESSION_ID,
48
+ STATE_FILE_JSON_INDENT_SPACES,
49
+ )
50
+
51
+
52
+ def _session_edit_file_path(session_id: str) -> Path:
53
+ """Return the per-session tracker file path in the system temp directory.
54
+
55
+ Args:
56
+ session_id: Raw ``session_id`` from the hook payload. Unsafe
57
+ characters are stripped so the value stays anchored inside the
58
+ temp directory, and an empty result falls back to the default id.
59
+
60
+ Returns:
61
+ Absolute path to this session's tracker file.
62
+ """
63
+ sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
64
+ effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
65
+ file_name = f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
66
+ return Path(tempfile.gettempdir()) / file_name
67
+
68
+
69
+ def _read_recorded_paths(edit_file: Path) -> list[str]:
70
+ """Return the file paths already recorded in a tracker file.
71
+
72
+ Args:
73
+ edit_file: Path to this session's tracker file.
74
+
75
+ Returns:
76
+ The recorded absolute paths, or an empty list when the file is
77
+ absent, unreadable, malformed, or the wrong shape.
78
+ """
79
+ try:
80
+ raw_contents = edit_file.read_text(encoding="utf-8")
81
+ except (FileNotFoundError, OSError):
82
+ return []
83
+ try:
84
+ parsed_payload = json.loads(raw_contents)
85
+ except json.JSONDecodeError:
86
+ return []
87
+ if not isinstance(parsed_payload, dict):
88
+ return []
89
+ recorded_paths = parsed_payload.get(ALL_EDITED_FILE_PATHS_KEY, [])
90
+ if not isinstance(recorded_paths, list):
91
+ return []
92
+ return [each_path for each_path in recorded_paths if isinstance(each_path, str)]
93
+
94
+
95
+ def _atomic_write_edit_file(edit_file: Path, all_edited_file_paths: list[str]) -> None:
96
+ """Write the tracker payload to disk atomically via tempfile plus rename.
97
+
98
+ Args:
99
+ edit_file: Destination tracker file path.
100
+ all_edited_file_paths: The full deduplicated path list to persist.
101
+ """
102
+ parent_directory = edit_file.parent
103
+ parent_directory.mkdir(parents=True, exist_ok=True)
104
+ encoded_text = json.dumps(
105
+ {ALL_EDITED_FILE_PATHS_KEY: all_edited_file_paths},
106
+ indent=STATE_FILE_JSON_INDENT_SPACES,
107
+ )
108
+ with tempfile.NamedTemporaryFile(
109
+ mode="w",
110
+ encoding="utf-8",
111
+ dir=str(parent_directory),
112
+ delete=False,
113
+ suffix=STATE_FILE_ATOMIC_WRITE_SUFFIX,
114
+ ) as temporary_handle:
115
+ temporary_handle.write(encoded_text)
116
+ temporary_path = Path(temporary_handle.name)
117
+ try:
118
+ os.replace(str(temporary_path), str(edit_file))
119
+ except OSError:
120
+ temporary_path.unlink(missing_ok=True)
121
+ raise
122
+
123
+
124
+ def _acquire_edit_file_lock(lock_file: Path) -> int | None:
125
+ """Grab an exclusive per-session lock, spinning until it frees or times out.
126
+
127
+ Args:
128
+ lock_file: Path to this session's lock file.
129
+
130
+ Returns:
131
+ An open file descriptor for the held lock, or None when the lock stayed
132
+ held past the acquire timeout so the caller proceeds without it rather
133
+ than stalling the edit that triggered the hook.
134
+ """
135
+ lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
136
+ while True:
137
+ try:
138
+ return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
139
+ except FileExistsError:
140
+ if time.monotonic() >= lock_acquire_deadline:
141
+ return None
142
+ time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
143
+
144
+
145
+ @contextlib.contextmanager
146
+ def _hold_edit_file_lock(edit_file: Path) -> Iterator[None]:
147
+ """Hold the per-session lock across one read-modify-write of the tracker.
148
+
149
+ Two PostToolUse invocations run concurrently when Claude issues parallel
150
+ Write or Edit calls in one turn. Serializing the read-then-write on a shared
151
+ lock file keeps each invocation's appended path from overwriting another's.
152
+ The lock is best-effort: when it cannot be acquired within the timeout, the
153
+ write proceeds without it rather than stalling the edit.
154
+
155
+ Args:
156
+ edit_file: This session's tracker file path.
157
+
158
+ Yields:
159
+ Control to the caller while the lock is held.
160
+ """
161
+ lock_file = edit_file.with_name(edit_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
162
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
163
+ lock_descriptor = _acquire_edit_file_lock(lock_file)
164
+ try:
165
+ yield
166
+ finally:
167
+ if lock_descriptor is not None:
168
+ os.close(lock_descriptor)
169
+ lock_file.unlink(missing_ok=True)
170
+
171
+
172
+ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
173
+ """Append one edited path to this session's tracker file when it is new.
174
+
175
+ The read of the recorded paths and the write back run under a best-effort
176
+ per-session lock, so parallel invocations do not drop one another's appended
177
+ path while the lock holds.
178
+
179
+ Args:
180
+ session_id: Raw ``session_id`` from the hook payload.
181
+ resolved_file_path: The resolved absolute path of the edited file.
182
+ """
183
+ edit_file = _session_edit_file_path(session_id)
184
+ with _hold_edit_file_lock(edit_file):
185
+ recorded_paths = _read_recorded_paths(edit_file)
186
+ if resolved_file_path in recorded_paths:
187
+ return
188
+ recorded_paths.append(resolved_file_path)
189
+ try:
190
+ _atomic_write_edit_file(edit_file, recorded_paths)
191
+ except OSError:
192
+ return
193
+
194
+
195
+ def main() -> None:
196
+ """Record the edited file path for a Write, Edit, or MultiEdit.
197
+
198
+ Reads the PostToolUse payload from stdin, and for a tracked edit tool
199
+ appends the resolved absolute file path to this session's tracker file.
200
+ Returns on every branch — a non-edit tool, a malformed payload, a missing
201
+ file path, or a failed write — so the tool call is never blocked.
202
+ """
203
+ hook_payload = read_hook_input_dictionary_from_stdin()
204
+ if hook_payload is None:
205
+ return
206
+ tool_name = hook_payload.get("tool_name", "")
207
+ if tool_name not in ALL_TRACKED_EDIT_TOOL_NAMES:
208
+ return
209
+ tool_input = hook_payload.get("tool_input", {})
210
+ if not isinstance(tool_input, dict):
211
+ return
212
+ file_path = tool_input.get("file_path", "")
213
+ if not isinstance(file_path, str) or not file_path:
214
+ return
215
+ try:
216
+ resolved_file_path = str(Path(file_path).resolve())
217
+ except OSError:
218
+ return
219
+ session_id = str(hook_payload.get("session_id") or "")
220
+ _record_edited_path(session_id, resolved_file_path)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
@@ -0,0 +1,174 @@
1
+ """Behavior tests for the session_file_edit_tracker PostToolUse hook.
2
+
3
+ The tracker records the resolved absolute file path of every Write, Edit, and
4
+ MultiEdit into a per-session JSON file under the system temp directory. These
5
+ tests drive the hook's ``main`` through real stdin payloads with
6
+ ``tempfile.gettempdir`` redirected to a temporary directory, then read the
7
+ JSON file back to confirm what was recorded.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import importlib.util
14
+ import io
15
+ import json
16
+ import pathlib
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from typing import Any
22
+ from unittest import mock
23
+
24
+ import pytest
25
+
26
+ _HOOK_DIR = pathlib.Path(__file__).parent
27
+ _HOOKS_TREE = _HOOK_DIR.parent
28
+ for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
29
+ if each_path not in sys.path:
30
+ sys.path.insert(0, each_path)
31
+
32
+ hook_spec = importlib.util.spec_from_file_location(
33
+ "session_file_edit_tracker",
34
+ _HOOK_DIR / "session_file_edit_tracker.py",
35
+ )
36
+ assert hook_spec is not None
37
+ assert hook_spec.loader is not None
38
+ hook_module = importlib.util.module_from_spec(hook_spec)
39
+ hook_spec.loader.exec_module(hook_module)
40
+
41
+ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
42
+ ALL_EDITED_FILE_PATHS_KEY,
43
+ SESSION_EDIT_FILE_PREFIX,
44
+ SESSION_EDIT_FILE_SUFFIX,
45
+ )
46
+
47
+ _SESSION_ID = "sessionabc123"
48
+ _CONCURRENT_EDIT_COUNT = 5
49
+ _CONCURRENT_READ_DELAY_SECONDS = 0.05
50
+
51
+
52
+ def _run_main_with_stdin(input_text: str) -> None:
53
+ with (
54
+ mock.patch("sys.stdin", io.StringIO(input_text)),
55
+ mock.patch("sys.stdout", new_callable=io.StringIO),
56
+ contextlib.suppress(SystemExit),
57
+ ):
58
+ hook_module.main()
59
+
60
+
61
+ @pytest.fixture()
62
+ def redirected_temp_directory(
63
+ tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
64
+ ) -> pathlib.Path:
65
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
66
+ return tmp_path
67
+
68
+
69
+ def _edit_file_path(temp_directory: pathlib.Path, session_id: str) -> pathlib.Path:
70
+ return temp_directory / f"{SESSION_EDIT_FILE_PREFIX}{session_id}{SESSION_EDIT_FILE_SUFFIX}"
71
+
72
+
73
+ def _read_recorded_paths(temp_directory: pathlib.Path, session_id: str) -> list[str]:
74
+ edit_file = _edit_file_path(temp_directory, session_id)
75
+ parsed = json.loads(edit_file.read_text(encoding="utf-8"))
76
+ return parsed[ALL_EDITED_FILE_PATHS_KEY]
77
+
78
+
79
+ def _write_payload(tool_name: str, file_path: pathlib.Path, session_id: str) -> str:
80
+ payload: dict[str, Any] = {
81
+ "session_id": session_id,
82
+ "tool_name": tool_name,
83
+ "tool_input": {"file_path": str(file_path)},
84
+ }
85
+ return json.dumps(payload)
86
+
87
+
88
+ def test_should_record_write_tool_edit(redirected_temp_directory: pathlib.Path) -> None:
89
+ edited_file = redirected_temp_directory / "module_under_edit.py"
90
+ _run_main_with_stdin(_write_payload("Write", edited_file, _SESSION_ID))
91
+ recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
92
+ assert str(edited_file.resolve()) in recorded
93
+
94
+
95
+ def test_should_record_edit_tool_edit(redirected_temp_directory: pathlib.Path) -> None:
96
+ edited_file = redirected_temp_directory / "module_under_edit.py"
97
+ _run_main_with_stdin(_write_payload("Edit", edited_file, _SESSION_ID))
98
+ recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
99
+ assert str(edited_file.resolve()) in recorded
100
+
101
+
102
+ def test_should_record_multiedit_tool_edit(redirected_temp_directory: pathlib.Path) -> None:
103
+ edited_file = redirected_temp_directory / "module_under_edit.py"
104
+ _run_main_with_stdin(_write_payload("MultiEdit", edited_file, _SESSION_ID))
105
+ recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
106
+ assert str(edited_file.resolve()) in recorded
107
+
108
+
109
+ def test_should_ignore_bash_tool(redirected_temp_directory: pathlib.Path) -> None:
110
+ bash_payload = json.dumps(
111
+ {
112
+ "session_id": _SESSION_ID,
113
+ "tool_name": "Bash",
114
+ "tool_input": {"command": "git status"},
115
+ }
116
+ )
117
+ _run_main_with_stdin(bash_payload)
118
+ assert not _edit_file_path(redirected_temp_directory, _SESSION_ID).exists()
119
+
120
+
121
+ def test_should_deduplicate_repeated_paths(redirected_temp_directory: pathlib.Path) -> None:
122
+ edited_file = redirected_temp_directory / "module_under_edit.py"
123
+ _run_main_with_stdin(_write_payload("Write", edited_file, _SESSION_ID))
124
+ _run_main_with_stdin(_write_payload("Edit", edited_file, _SESSION_ID))
125
+ recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
126
+ assert recorded.count(str(edited_file.resolve())) == 1
127
+
128
+
129
+ def test_should_sanitize_unsafe_session_id_in_filename(
130
+ redirected_temp_directory: pathlib.Path,
131
+ ) -> None:
132
+ unsafe_session_id = "a/../b c"
133
+ edited_file = redirected_temp_directory / "module_under_edit.py"
134
+ _run_main_with_stdin(_write_payload("Write", edited_file, unsafe_session_id))
135
+ sanitized_file = _edit_file_path(redirected_temp_directory, "abc")
136
+ assert sanitized_file.exists()
137
+
138
+
139
+ def test_should_record_every_path_under_concurrent_edits(
140
+ redirected_temp_directory: pathlib.Path, monkeypatch: pytest.MonkeyPatch
141
+ ) -> None:
142
+ concurrent_session_id = "concurrentsession"
143
+ real_read_recorded_paths = hook_module._read_recorded_paths
144
+
145
+ def slow_read_recorded_paths(edit_file: pathlib.Path) -> list[str]:
146
+ recorded_before_delay = real_read_recorded_paths(edit_file)
147
+ time.sleep(_CONCURRENT_READ_DELAY_SECONDS)
148
+ return recorded_before_delay
149
+
150
+ monkeypatch.setattr(hook_module, "_read_recorded_paths", slow_read_recorded_paths)
151
+ all_edited_paths = [
152
+ str((redirected_temp_directory / f"module_{each_index}.py").resolve())
153
+ for each_index in range(_CONCURRENT_EDIT_COUNT)
154
+ ]
155
+ all_threads = [
156
+ threading.Thread(
157
+ target=hook_module._record_edited_path,
158
+ args=(concurrent_session_id, each_path),
159
+ )
160
+ for each_path in all_edited_paths
161
+ ]
162
+ for each_thread in all_threads:
163
+ each_thread.start()
164
+ for each_thread in all_threads:
165
+ each_thread.join()
166
+ recorded = _read_recorded_paths(redirected_temp_directory, concurrent_session_id)
167
+ assert sorted(recorded) == sorted(all_edited_paths)
168
+
169
+
170
+ def test_should_exit_zero_on_malformed_stdin(
171
+ redirected_temp_directory: pathlib.Path,
172
+ ) -> None:
173
+ _run_main_with_stdin("not valid json {{{")
174
+ assert not any(redirected_temp_directory.iterdir())
@@ -1,6 +1,6 @@
1
1
  # hooks/session
2
2
 
3
- SessionStart and SessionEnd hooks that manage per-session resources: cleaning up stale directories at startup and pruning plugin data at shutdown.
3
+ SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, detecting unregistered repositories, starting the session's task-list maintenance loop, and clearing PR-author swap state at shutdown.
4
4
 
5
5
  ## Key files
6
6
 
@@ -8,11 +8,15 @@ SessionStart and SessionEnd hooks that manage per-session resources: cleaning up
8
8
  |---|---|---|
9
9
  | `session_env_cleanup.py` | SessionStart | Removes the current session's pre-existing `~/.claude/session-env/<session_id>/` directory and prunes sibling entries older than the stale-age threshold. Prevents `EEXIST` errors from non-recursive `mkdir` calls in the Bash tool on Windows. |
10
10
  | `gh_pr_author_session_cleanup.py` | SessionEnd | Clears any PR-author swap state left over from the current session |
11
- | `plugin_data_dir_cleanup.py` | SessionEnd | Prunes stale plugin data directories |
11
+ | `session_edit_tracker_cleanup.py` | SessionStart, SessionEnd | Deletes the tracker file for the running Claude Code conversation from the system temp directory — at start for a clean slate and at end for a clean exit. A tracker is read only by the conversation that wrote it, so a live idle tracker is kept while a peer cleans up |
12
+ | `plugin_data_dir_cleanup.py` | SessionStart | Removes empty plugin data directories at startup to prevent `EEXIST` when Claude Code recreates them |
12
13
  | `untracked_repo_detector.py` | SessionStart | Detects when the session cwd is inside a git repository that is not registered in `~/.claude/project-paths.json` and logs a warning |
14
+ | `task_list_loop_starter.py` | SessionStart | Emits an `additionalContext` directive telling Claude to keep the task list current on a 10-minute cadence, starting the `/loop` skill when one is not already running. Writes nothing and runs no tools itself. |
13
15
  | `test_gh_pr_author_session_cleanup.py` | — | Tests for `gh_pr_author_session_cleanup.py` |
16
+ | `test_session_edit_tracker_cleanup.py` | — | Tests for `session_edit_tracker_cleanup.py` |
14
17
  | `test_session_env_cleanup.py` | — | Tests for `session_env_cleanup.py` |
15
18
  | `test_untracked_repo_detector.py` | — | Tests for `untracked_repo_detector.py` |
19
+ | `test_task_list_loop_starter.py` | — | Tests for `task_list_loop_starter.py` |
16
20
 
17
21
  ## Conventions
18
22
 
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart and SessionEnd hook: clear this session's own tracker file.
3
+
4
+ The session-edit tracker writes a per-session JSON file under the system temp
5
+ directory, and the stage gate reads only the running session's own file at
6
+ commit time. This hook deletes that file only when the session is genuinely
7
+ beginning or ending: at a fresh SessionStart (a ``startup`` source) so a new
8
+ session begins with an empty record, and at session end so a clean exit leaves
9
+ nothing behind. A SessionStart that continues the same conversation — a compact
10
+ or a resume — keeps the in-flight tracker, so an edit already recorded this
11
+ session survives the continuation.
12
+
13
+ A tracker file belongs to one session and is read only by that same session, so
14
+ a file a hard-crashed session leaves behind is inert — no other session ever
15
+ reads it. Cleanup keys off the session lifecycle rather than a file's age, so a
16
+ live session that pauses for a long review gap keeps its own tracker: no peer
17
+ session deletes another session's file.
18
+
19
+ The hook exits zero on every branch and never blocks the session.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
+ if _hooks_dir not in sys.path:
30
+ sys.path.insert(0, _hooks_dir)
31
+
32
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
33
+ read_hook_input_dictionary_from_stdin,
34
+ )
35
+ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
36
+ SESSION_EDIT_FILE_PREFIX,
37
+ SESSION_EDIT_FILE_SUFFIX,
38
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
39
+ SESSION_START_SOURCE_FRESH_STARTUP,
40
+ SESSION_START_SOURCE_PAYLOAD_KEY,
41
+ STATE_FILE_DEFAULT_SESSION_ID,
42
+ )
43
+
44
+
45
+ def _delete_file(target_file: Path) -> None:
46
+ """Remove a file, ignoring an already-absent file or a delete failure.
47
+
48
+ Args:
49
+ target_file: Path to delete.
50
+ """
51
+ try:
52
+ target_file.unlink()
53
+ except FileNotFoundError:
54
+ return
55
+ except OSError:
56
+ return
57
+
58
+
59
+ def _current_session_file_name(session_id: str) -> str:
60
+ """Return the tracker file name that belongs to the current session.
61
+
62
+ Args:
63
+ session_id: Raw ``session_id`` from the session-lifecycle payload.
64
+
65
+ Returns:
66
+ The tracker file name for the sanitized session id, falling back to
67
+ the default id when sanitizing leaves an empty string.
68
+ """
69
+ sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
70
+ effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
71
+ return f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
72
+
73
+
74
+ def _is_tracker_deletion_lifecycle_event(payload_by_field: dict[str, object]) -> bool:
75
+ """Report whether this lifecycle event should delete the session tracker.
76
+
77
+ The ``source`` key rides only on a SessionStart payload. A fresh startup
78
+ carries the ``startup`` source and a session end carries no source at all,
79
+ so both delete the tracker. A SessionStart that continues the same
80
+ conversation carries a non-startup source (a compact or a resume) and keeps
81
+ the in-flight tracker.
82
+
83
+ Args:
84
+ payload_by_field: The parsed session-lifecycle payload.
85
+
86
+ Returns:
87
+ True when the tracker should be deleted, False when a continuation
88
+ should keep it.
89
+ """
90
+ session_start_source = str(
91
+ payload_by_field.get(SESSION_START_SOURCE_PAYLOAD_KEY) or ""
92
+ )
93
+ if not session_start_source:
94
+ return True
95
+ return session_start_source == SESSION_START_SOURCE_FRESH_STARTUP
96
+
97
+
98
+ def main() -> None:
99
+ """Delete the running session's own tracker file at a fresh start or end.
100
+
101
+ Reads the session-lifecycle payload from stdin for the session id and
102
+ deletes that session's tracker file from the system temp directory, unless
103
+ the payload is a SessionStart that continues the same conversation (a
104
+ compact or a resume), which keeps the in-flight tracker. A malformed or
105
+ empty payload is a no-op that deletes nothing. Exits zero on every branch,
106
+ so the session is never blocked.
107
+ """
108
+ payload_by_field = read_hook_input_dictionary_from_stdin()
109
+ if payload_by_field is None:
110
+ return
111
+ if not _is_tracker_deletion_lifecycle_event(payload_by_field):
112
+ return
113
+ session_id = str(payload_by_field.get("session_id") or "")
114
+ temp_directory = Path(tempfile.gettempdir())
115
+ current_session_file_name = _current_session_file_name(session_id)
116
+ _delete_file(temp_directory / current_session_file_name)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()