claude-dev-env 1.84.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.
- package/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +143 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +306 -64
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +200 -21
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/code_rules_docstrings.py +17 -13
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_naming_collection.py +5 -3
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/re-stage-before-commit.md +31 -0
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +46 -3
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/pr-converge/SKILL.md +27 -1
- package/skills/pr-converge/reference/state-schema.md +12 -0
|
@@ -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` |
|
|
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())
|
package/hooks/session/CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hooks/session
|
|
2
2
|
|
|
3
|
-
SessionStart and SessionEnd hooks
|
|
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
|
-
| `
|
|
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()
|