claude-dev-env 8.50.1 → 8.51.1
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/docs/rule-guides/session-title.md +25 -0
- package/hooks/blocking/session_title_format_gate.py +114 -0
- package/hooks/blocking/session_title_stop_gate.py +185 -0
- package/hooks/blocking/test_session_title_format_gate.py +130 -0
- package/hooks/blocking/test_session_title_stop_gate.py +209 -0
- package/hooks/hooks.json +21 -0
- package/hooks/hooks_constants/session_title_constants.py +55 -0
- package/package.json +1 -1
- package/rules/session-title.md +9 -0
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/sweep_empty_dirs.py +40 -30
- package/scripts/test_sweep_empty_dirs.py +11 -0
- package/scripts/tests/test_adapter_configuration.py +11 -0
- package/scripts/tests/test_rule_load_scopes.py +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Back to the [rule entry](../../rules/session-title.md).
|
|
2
|
+
|
|
3
|
+
# Status in the session title
|
|
4
|
+
|
|
5
|
+
**When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
|
|
6
|
+
|
|
7
|
+
## Rule
|
|
8
|
+
|
|
9
|
+
Before each turn ends, set the title to `<emoji> <name>`. Get the session id from `get_session` with no `session_id` when the tool asks for one.
|
|
10
|
+
|
|
11
|
+
## Status emoji
|
|
12
|
+
|
|
13
|
+
| Emoji | Use it when |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Red flag | The user must act: a question, an approval, or a step only the user can do. |
|
|
16
|
+
| Check mark | The work is done: merged, or nothing left. |
|
|
17
|
+
| Hourglass | Work, CI, or a merge queue still runs and nothing waits on the user. |
|
|
18
|
+
|
|
19
|
+
## Name
|
|
20
|
+
|
|
21
|
+
The name has 25 characters or fewer and names the main change or outcome in concrete nouns, such as `Broker gate + replay rule`. Join two parts with ` + `. Use sentence case and no end punctuation. Leave out dates, IDs, branch names, and filler words such as "work on" or "fix for". Rename the session when the scope of the work changes.
|
|
22
|
+
|
|
23
|
+
## Enforcement
|
|
24
|
+
|
|
25
|
+
`hooks/blocking/session_title_format_gate.py` denies a malformed title. `hooks/blocking/session_title_stop_gate.py` blocks the end of a turn that set no title.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that keeps every session title in the '<emoji> <name>' shape.
|
|
3
|
+
|
|
4
|
+
The gate reads the title a tool whose name ends in __set_session_title is about
|
|
5
|
+
to set. It denies the call when the title breaks one of these rules:
|
|
6
|
+
|
|
7
|
+
- It starts with one status emoji (red flag, check mark or hourglass) and a space.
|
|
8
|
+
- It is one line.
|
|
9
|
+
- The name after the emoji has 1 to 25 characters.
|
|
10
|
+
- It ends without punctuation.
|
|
11
|
+
- The name starts with a capital letter.
|
|
12
|
+
- The name holds no run of three or more digits and no slash.
|
|
13
|
+
|
|
14
|
+
A call with no string title passes, so a tool with another schema keeps working.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if hooks_root_directory not in sys.path:
|
|
24
|
+
sys.path.insert(0, hooks_root_directory)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
27
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
28
|
+
from hooks_constants.session_title_constants import (
|
|
29
|
+
ALLOW_EXIT_CODE,
|
|
30
|
+
BLOCK_EXIT_CODE,
|
|
31
|
+
BRANCH_NAME_MESSAGE,
|
|
32
|
+
FORMAT_GATE_EVENT_NAME,
|
|
33
|
+
FORMAT_RETRY_INSTRUCTION,
|
|
34
|
+
IDENTIFIER_DIGITS_PATTERN,
|
|
35
|
+
IDENTIFIER_MESSAGE,
|
|
36
|
+
MAXIMUM_NAME_LENGTH,
|
|
37
|
+
NAME_LENGTH_MESSAGE,
|
|
38
|
+
SENTENCE_CASE_MESSAGE,
|
|
39
|
+
SINGLE_LINE_MESSAGE,
|
|
40
|
+
STATUS_PREFIX_MESSAGE,
|
|
41
|
+
STATUS_PREFIX_PATTERN,
|
|
42
|
+
TITLE_INPUT_KEY,
|
|
43
|
+
TITLE_TOOL_NAME_PATTERN,
|
|
44
|
+
TOOL_INPUT_KEY,
|
|
45
|
+
TOOL_NAME_KEY,
|
|
46
|
+
TRAILING_PUNCTUATION,
|
|
47
|
+
TRAILING_PUNCTUATION_MESSAGE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _name_violation(name: str) -> str | None:
|
|
52
|
+
if name != name.strip() or not 1 <= len(name) <= MAXIMUM_NAME_LENGTH:
|
|
53
|
+
return NAME_LENGTH_MESSAGE.format(maximum=MAXIMUM_NAME_LENGTH, length=len(name.strip()))
|
|
54
|
+
if name[-1] in TRAILING_PUNCTUATION:
|
|
55
|
+
return TRAILING_PUNCTUATION_MESSAGE
|
|
56
|
+
if name[0].isalpha() and not name[0].isupper():
|
|
57
|
+
return SENTENCE_CASE_MESSAGE
|
|
58
|
+
if IDENTIFIER_DIGITS_PATTERN.search(name):
|
|
59
|
+
return IDENTIFIER_MESSAGE
|
|
60
|
+
if "/" in name:
|
|
61
|
+
return BRANCH_NAME_MESSAGE
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def title_format_violation(title: str) -> str | None:
|
|
66
|
+
"""Return the first rule a session title breaks, or None when it is well formed.
|
|
67
|
+
|
|
68
|
+
>>> title_format_violation("\u23f3 Broker gate + replay rule") is None
|
|
69
|
+
True
|
|
70
|
+
>>> title_format_violation("Broker gate") == STATUS_PREFIX_MESSAGE
|
|
71
|
+
True
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
title: The title the tool call would set.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The message for the broken rule, or None.
|
|
78
|
+
"""
|
|
79
|
+
if "\n" in title or "\r" in title:
|
|
80
|
+
return SINGLE_LINE_MESSAGE
|
|
81
|
+
status_prefix = STATUS_PREFIX_PATTERN.match(title)
|
|
82
|
+
if status_prefix is None:
|
|
83
|
+
return STATUS_PREFIX_MESSAGE
|
|
84
|
+
return _name_violation(title[status_prefix.end() :])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> int:
|
|
88
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
89
|
+
if hook_input is None:
|
|
90
|
+
return ALLOW_EXIT_CODE
|
|
91
|
+
tool_name = hook_input.get(TOOL_NAME_KEY)
|
|
92
|
+
if not isinstance(tool_name, str) or not TITLE_TOOL_NAME_PATTERN.match(tool_name):
|
|
93
|
+
return ALLOW_EXIT_CODE
|
|
94
|
+
tool_input = hook_input.get(TOOL_INPUT_KEY)
|
|
95
|
+
title = tool_input.get(TITLE_INPUT_KEY) if isinstance(tool_input, dict) else None
|
|
96
|
+
if not isinstance(title, str):
|
|
97
|
+
return ALLOW_EXIT_CODE
|
|
98
|
+
violation = title_format_violation(title)
|
|
99
|
+
if violation is None:
|
|
100
|
+
return ALLOW_EXIT_CODE
|
|
101
|
+
block_reason = violation + FORMAT_RETRY_INSTRUCTION
|
|
102
|
+
log_hook_block(
|
|
103
|
+
Path(__file__).name,
|
|
104
|
+
FORMAT_GATE_EVENT_NAME,
|
|
105
|
+
block_reason,
|
|
106
|
+
tool_name=tool_name,
|
|
107
|
+
offending_input_preview=title,
|
|
108
|
+
)
|
|
109
|
+
sys.stderr.write(block_reason)
|
|
110
|
+
return BLOCK_EXIT_CODE
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
sys.exit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook that ends each turn only after the session title shows its status.
|
|
3
|
+
|
|
4
|
+
A turn starts at the latest user prompt in the transcript, skipping tool
|
|
5
|
+
results and meta entries. The turn may end once a tool whose name ends in
|
|
6
|
+
__set_session_title ran in it without error. Otherwise the hook blocks the
|
|
7
|
+
stop once and tells the model to set '<emoji> <name>'.
|
|
8
|
+
|
|
9
|
+
The hook stays silent when:
|
|
10
|
+
|
|
11
|
+
- the stop is already a retry after a block (stop_hook_active is true);
|
|
12
|
+
- the session shows no title tool: no remote session id in the environment,
|
|
13
|
+
and no title tool named anywhere in the transcript;
|
|
14
|
+
- the transcript cannot be read.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
25
|
+
if hooks_root_directory not in sys.path:
|
|
26
|
+
sys.path.insert(0, hooks_root_directory)
|
|
27
|
+
|
|
28
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
29
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
30
|
+
from hooks_constants.session_title_constants import (
|
|
31
|
+
ALLOW_EXIT_CODE,
|
|
32
|
+
BLOCK_DECISION,
|
|
33
|
+
DEFERRED_TOOL_ATTACHMENT_TYPES,
|
|
34
|
+
REMOTE_SESSION_ENVIRONMENT_VARIABLE,
|
|
35
|
+
REMOTE_TITLE_TOOL_NAME,
|
|
36
|
+
STOP_BLOCK_REASON,
|
|
37
|
+
STOP_GATE_EVENT_NAME,
|
|
38
|
+
STOP_HOOK_ACTIVE_KEY,
|
|
39
|
+
TITLE_TOOL_NAME_PATTERN,
|
|
40
|
+
TRANSCRIPT_PATH_KEY,
|
|
41
|
+
UNKNOWN_TITLE_TOOL_NAME,
|
|
42
|
+
)
|
|
43
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _content_blocks(all_entry_fields: dict[str, object]) -> list[dict[str, object]]:
|
|
47
|
+
message = all_entry_fields.get("message")
|
|
48
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
49
|
+
if not isinstance(content, list):
|
|
50
|
+
return []
|
|
51
|
+
return [each_block for each_block in content if isinstance(each_block, dict)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
|
|
55
|
+
if all_entry_fields.get("type") != "user" or all_entry_fields.get("isMeta") is True:
|
|
56
|
+
return False
|
|
57
|
+
message = all_entry_fields.get("message")
|
|
58
|
+
if isinstance(message, dict) and isinstance(message.get("content"), str):
|
|
59
|
+
return True
|
|
60
|
+
return any(each_block.get("type") == "text" for each_block in _content_blocks(all_entry_fields))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _title_tool_names_in(all_entry_fields: dict[str, object]) -> list[str]:
|
|
64
|
+
all_names: list[str] = []
|
|
65
|
+
if all_entry_fields.get("type") == "assistant":
|
|
66
|
+
all_names = [
|
|
67
|
+
str(each_block.get("name"))
|
|
68
|
+
for each_block in _content_blocks(all_entry_fields)
|
|
69
|
+
if each_block.get("type") == "tool_use"
|
|
70
|
+
]
|
|
71
|
+
attachment = all_entry_fields.get("attachment")
|
|
72
|
+
if isinstance(attachment, dict) and attachment.get("type") in DEFERRED_TOOL_ATTACHMENT_TYPES:
|
|
73
|
+
all_names += [str(each_name) for each_name in attachment.get("addedNames") or []]
|
|
74
|
+
all_names += [
|
|
75
|
+
str(each_record.get("name"))
|
|
76
|
+
for each_record in attachment.get("entries") or []
|
|
77
|
+
if isinstance(each_record, dict)
|
|
78
|
+
]
|
|
79
|
+
return [each_name for each_name in all_names if TITLE_TOOL_NAME_PATTERN.match(each_name)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_title_call(entry_type: object, all_block_fields: dict[str, object]) -> bool:
|
|
83
|
+
return (
|
|
84
|
+
entry_type == "assistant"
|
|
85
|
+
and all_block_fields.get("type") == "tool_use"
|
|
86
|
+
and bool(TITLE_TOOL_NAME_PATTERN.match(str(all_block_fields.get("name"))))
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _title_set_since(all_turn_entries: list[dict[str, object]]) -> bool:
|
|
91
|
+
all_entry_blocks = [
|
|
92
|
+
(each_entry.get("type"), each_block)
|
|
93
|
+
for each_entry in all_turn_entries
|
|
94
|
+
for each_block in _content_blocks(each_entry)
|
|
95
|
+
]
|
|
96
|
+
all_title_call_ids = {
|
|
97
|
+
str(each_block.get("id"))
|
|
98
|
+
for each_entry_type, each_block in all_entry_blocks
|
|
99
|
+
if _is_title_call(each_entry_type, each_block)
|
|
100
|
+
}
|
|
101
|
+
return any(
|
|
102
|
+
each_block.get("type") == "tool_result"
|
|
103
|
+
and str(each_block.get("tool_use_id")) in all_title_call_ids
|
|
104
|
+
and not each_block.get("is_error")
|
|
105
|
+
for _, each_block in all_entry_blocks
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _turn_start_index(all_entries: list[dict[str, object]]) -> int:
|
|
110
|
+
return max(
|
|
111
|
+
(
|
|
112
|
+
each_index
|
|
113
|
+
for each_index, each_entry in enumerate(all_entries)
|
|
114
|
+
if _is_user_prompt(each_entry)
|
|
115
|
+
),
|
|
116
|
+
default=0,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: bool) -> str | None:
|
|
121
|
+
"""Return the block reason when this turn has not set the title, else None.
|
|
122
|
+
|
|
123
|
+
::
|
|
124
|
+
|
|
125
|
+
prompt -> set_session_title -> ok result => None
|
|
126
|
+
prompt -> work, no title call => block reason
|
|
127
|
+
no title tool anywhere, not remote => None
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
all_entries: The transcript entries, oldest first.
|
|
131
|
+
is_remote_session: True when the session runs in a cloud session with a title tool.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The reason to send back to the model, or None to let the turn end.
|
|
135
|
+
"""
|
|
136
|
+
all_seen_tool_names = [
|
|
137
|
+
each_name for each_entry in all_entries for each_name in _title_tool_names_in(each_entry)
|
|
138
|
+
]
|
|
139
|
+
if not all_seen_tool_names and not is_remote_session:
|
|
140
|
+
return None
|
|
141
|
+
if _title_set_since(all_entries[_turn_start_index(all_entries) :]):
|
|
142
|
+
return None
|
|
143
|
+
if all_seen_tool_names:
|
|
144
|
+
return STOP_BLOCK_REASON.format(tool_name=all_seen_tool_names[-1])
|
|
145
|
+
tool_name = REMOTE_TITLE_TOOL_NAME if is_remote_session else UNKNOWN_TITLE_TOOL_NAME
|
|
146
|
+
return STOP_BLOCK_REASON.format(tool_name=tool_name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parsed_entry(line: str) -> dict[str, object] | None:
|
|
150
|
+
try:
|
|
151
|
+
parsed_entry = json.loads(line)
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
return None
|
|
154
|
+
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _read_entries(transcript_path: str) -> list[dict[str, object]]:
|
|
158
|
+
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
159
|
+
all_lines = transcript.readlines()
|
|
160
|
+
all_parsed_entries = [_parsed_entry(each_line) for each_line in all_lines]
|
|
161
|
+
return [each_entry for each_entry in all_parsed_entries if each_entry is not None]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> int:
|
|
165
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
166
|
+
if hook_input is None or hook_input.get(STOP_HOOK_ACTIVE_KEY):
|
|
167
|
+
return ALLOW_EXIT_CODE
|
|
168
|
+
transcript_path = hook_input.get(TRANSCRIPT_PATH_KEY)
|
|
169
|
+
if not isinstance(transcript_path, str) or not transcript_path:
|
|
170
|
+
return ALLOW_EXIT_CODE
|
|
171
|
+
try:
|
|
172
|
+
all_entries = _read_entries(transcript_path)
|
|
173
|
+
except OSError:
|
|
174
|
+
return ALLOW_EXIT_CODE
|
|
175
|
+
is_remote_session = bool(os.environ.get(REMOTE_SESSION_ENVIRONMENT_VARIABLE))
|
|
176
|
+
block_reason = stop_block_reason(all_entries, is_remote_session)
|
|
177
|
+
if block_reason is None:
|
|
178
|
+
return ALLOW_EXIT_CODE
|
|
179
|
+
log_hook_block(Path(__file__).name, STOP_GATE_EVENT_NAME, block_reason)
|
|
180
|
+
sys.stdout.write(json.dumps({"decision": BLOCK_DECISION, "reason": block_reason}))
|
|
181
|
+
return ALLOW_EXIT_CODE
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import session_title_format_gate
|
|
8
|
+
from hooks_constants.session_title_constants import (
|
|
9
|
+
BRANCH_NAME_MESSAGE,
|
|
10
|
+
IDENTIFIER_MESSAGE,
|
|
11
|
+
SENTENCE_CASE_MESSAGE,
|
|
12
|
+
SINGLE_LINE_MESSAGE,
|
|
13
|
+
STATUS_PREFIX_MESSAGE,
|
|
14
|
+
TRAILING_PUNCTUATION_MESSAGE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
REMOTE_TITLE_TOOL = "mcp__claude-code-remote__set_session_title"
|
|
18
|
+
DESKTOP_TITLE_TOOL = "mcp__ccd_session_mgmt__set_session_title"
|
|
19
|
+
RED_FLAG = "\U0001f6a9"
|
|
20
|
+
CHECK_MARK = "✅"
|
|
21
|
+
HOURGLASS = "⏳"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_gate(
|
|
25
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
26
|
+
capsys: pytest.CaptureFixture[str],
|
|
27
|
+
tool_name: str,
|
|
28
|
+
tool_input: object,
|
|
29
|
+
) -> tuple[int, str]:
|
|
30
|
+
hook_input = {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
31
|
+
monkeypatch.setattr(
|
|
32
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
33
|
+
)
|
|
34
|
+
exit_code = session_title_format_gate.main()
|
|
35
|
+
return exit_code, capsys.readouterr().err
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(autouse=True)
|
|
39
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
40
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
41
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("tool_name", [REMOTE_TITLE_TOOL, DESKTOP_TITLE_TOOL])
|
|
45
|
+
@pytest.mark.parametrize(
|
|
46
|
+
"title",
|
|
47
|
+
[
|
|
48
|
+
f"{HOURGLASS} Broker gate + replay rule",
|
|
49
|
+
f"{CHECK_MARK} Session title gate",
|
|
50
|
+
f"{RED_FLAG}️ Runner sign-in",
|
|
51
|
+
f"{RED_FLAG} CI gate + docs",
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
def test_should_allow_a_well_formed_title_on_each_title_tool(
|
|
55
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tool_name: str, title: str
|
|
56
|
+
) -> None:
|
|
57
|
+
assert run_gate(monkeypatch, capsys, tool_name, {"session_id": "s", "title": title}) == (0, "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
("title", "expected_message"),
|
|
62
|
+
[
|
|
63
|
+
("Broker gate + replay rule", STATUS_PREFIX_MESSAGE),
|
|
64
|
+
("\U0001f680 Broker gate", STATUS_PREFIX_MESSAGE),
|
|
65
|
+
(f"{HOURGLASS}Broker gate", STATUS_PREFIX_MESSAGE),
|
|
66
|
+
(f"{HOURGLASS} Broker gate\nand more", SINGLE_LINE_MESSAGE),
|
|
67
|
+
(f"{HOURGLASS} Broker gate.", TRAILING_PUNCTUATION_MESSAGE),
|
|
68
|
+
(f"{HOURGLASS} broker gate", SENTENCE_CASE_MESSAGE),
|
|
69
|
+
(f"{HOURGLASS} PR 5608 review", IDENTIFIER_MESSAGE),
|
|
70
|
+
(f"{HOURGLASS} Plan for 2026-10-04", IDENTIFIER_MESSAGE),
|
|
71
|
+
(f"{HOURGLASS} Feature/title cleanup", BRANCH_NAME_MESSAGE),
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
def test_should_deny_a_title_and_name_the_rule_it_breaks(
|
|
75
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
76
|
+
capsys: pytest.CaptureFixture[str],
|
|
77
|
+
title: str,
|
|
78
|
+
expected_message: str,
|
|
79
|
+
) -> None:
|
|
80
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, REMOTE_TITLE_TOOL, {"title": title})
|
|
81
|
+
assert exit_code == 2
|
|
82
|
+
assert stderr_text.startswith(expected_message)
|
|
83
|
+
assert "Call the title tool again" in stderr_text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_should_deny_a_name_longer_than_twenty_five_characters(
|
|
87
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
88
|
+
) -> None:
|
|
89
|
+
exit_code, stderr_text = run_gate(
|
|
90
|
+
monkeypatch,
|
|
91
|
+
capsys,
|
|
92
|
+
DESKTOP_TITLE_TOOL,
|
|
93
|
+
{"title": f"{CHECK_MARK} Broker gate and replay rules"},
|
|
94
|
+
)
|
|
95
|
+
assert exit_code == 2
|
|
96
|
+
assert "1 to 25 characters; it has 28" in stderr_text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_should_allow_a_name_of_exactly_twenty_five_characters(
|
|
100
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
101
|
+
) -> None:
|
|
102
|
+
title = f"{CHECK_MARK} " + "A" * 25
|
|
103
|
+
assert run_gate(monkeypatch, capsys, REMOTE_TITLE_TOOL, {"title": title}) == (0, "")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize(
|
|
107
|
+
("tool_name", "tool_input"),
|
|
108
|
+
[
|
|
109
|
+
("mcp__chat__update_message", {"title": "fix stuff."}),
|
|
110
|
+
(REMOTE_TITLE_TOOL, {"name": "fix stuff."}),
|
|
111
|
+
(REMOTE_TITLE_TOOL, "fix stuff."),
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
def test_should_leave_other_tools_and_schemas_alone(
|
|
115
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
116
|
+
capsys: pytest.CaptureFixture[str],
|
|
117
|
+
tool_name: str,
|
|
118
|
+
tool_input: object,
|
|
119
|
+
) -> None:
|
|
120
|
+
assert run_gate(monkeypatch, capsys, tool_name, tool_input) == (0, "")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_title_format_violation_returns_none_for_a_good_title() -> None:
|
|
124
|
+
assert session_title_format_gate.title_format_violation(f"{RED_FLAG} Runner sign-in") is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_title_format_violation_names_the_missing_emoji() -> None:
|
|
128
|
+
assert (
|
|
129
|
+
session_title_format_gate.title_format_violation("Runner sign-in") == STATUS_PREFIX_MESSAGE
|
|
130
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import session_title_stop_gate
|
|
8
|
+
|
|
9
|
+
REMOTE_TITLE_TOOL = "mcp__claude-code-remote__set_session_title"
|
|
10
|
+
DESKTOP_TITLE_TOOL = "mcp__ccd_session_mgmt__set_session_title"
|
|
11
|
+
REMOTE_SESSION_VARIABLE = "CLAUDE_CODE_REMOTE_SESSION_ID"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prompt(text: str) -> dict[str, object]:
|
|
15
|
+
return {"type": "user", "message": {"role": "user", "content": text}}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def meta_prompt(text: str) -> dict[str, object]:
|
|
19
|
+
return {
|
|
20
|
+
"type": "user",
|
|
21
|
+
"isMeta": True,
|
|
22
|
+
"message": {"role": "user", "content": [{"type": "text", "text": text}]},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tool_call(tool_use_id: str, name: str) -> dict[str, object]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "assistant",
|
|
29
|
+
"message": {
|
|
30
|
+
"role": "assistant",
|
|
31
|
+
"content": [{"type": "tool_use", "id": tool_use_id, "name": name, "input": {}}],
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tool_result(tool_use_id: str, is_error: bool = False) -> dict[str, object]:
|
|
37
|
+
return {
|
|
38
|
+
"type": "user",
|
|
39
|
+
"message": {
|
|
40
|
+
"role": "user",
|
|
41
|
+
"content": [
|
|
42
|
+
{
|
|
43
|
+
"type": "tool_result",
|
|
44
|
+
"tool_use_id": tool_use_id,
|
|
45
|
+
"content": "ok",
|
|
46
|
+
"is_error": is_error,
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def deferred_tools(*all_names: str) -> dict[str, object]:
|
|
54
|
+
return {
|
|
55
|
+
"type": "attachment",
|
|
56
|
+
"attachment": {"type": "deferred_tools_delta", "addedNames": list(all_names)},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_gate(
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
capsys: pytest.CaptureFixture[str],
|
|
63
|
+
tmp_path: Path,
|
|
64
|
+
all_entries: list[dict[str, object]],
|
|
65
|
+
stop_hook_active: bool = False,
|
|
66
|
+
) -> tuple[int, str]:
|
|
67
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
68
|
+
transcript_path.write_text(
|
|
69
|
+
"".join(json.dumps(each) + "\n" for each in all_entries), encoding="utf-8"
|
|
70
|
+
)
|
|
71
|
+
hook_input = {
|
|
72
|
+
"hook_event_name": "Stop",
|
|
73
|
+
"transcript_path": str(transcript_path),
|
|
74
|
+
"stop_hook_active": stop_hook_active,
|
|
75
|
+
}
|
|
76
|
+
monkeypatch.setattr(
|
|
77
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
78
|
+
)
|
|
79
|
+
exit_code = session_title_stop_gate.main()
|
|
80
|
+
return exit_code, capsys.readouterr().out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.fixture(autouse=True)
|
|
84
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
85
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
86
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
87
|
+
monkeypatch.delenv(REMOTE_SESSION_VARIABLE, raising=False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_should_block_a_remote_turn_that_set_no_title(
|
|
91
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
92
|
+
) -> None:
|
|
93
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
94
|
+
exit_code, stdout_text = run_gate(
|
|
95
|
+
monkeypatch,
|
|
96
|
+
capsys,
|
|
97
|
+
tmp_path,
|
|
98
|
+
[prompt("Fix the runner"), tool_call("t1", "Bash"), tool_result("t1")],
|
|
99
|
+
)
|
|
100
|
+
decision = json.loads(stdout_text)
|
|
101
|
+
assert exit_code == 0
|
|
102
|
+
assert decision["decision"] == "block"
|
|
103
|
+
assert REMOTE_TITLE_TOOL in decision["reason"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_should_pass_a_turn_whose_title_call_succeeded(
|
|
107
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
108
|
+
) -> None:
|
|
109
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
110
|
+
all_entries = [prompt("Fix the runner"), tool_call("t1", REMOTE_TITLE_TOOL), tool_result("t1")]
|
|
111
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_should_block_when_the_only_title_call_was_denied(
|
|
115
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
116
|
+
) -> None:
|
|
117
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
118
|
+
all_entries = [
|
|
119
|
+
prompt("Fix the runner"),
|
|
120
|
+
tool_call("t1", REMOTE_TITLE_TOOL),
|
|
121
|
+
tool_result("t1", is_error=True),
|
|
122
|
+
]
|
|
123
|
+
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
124
|
+
assert json.loads(stdout_text)["decision"] == "block"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_block_when_the_title_was_set_only_in_an_earlier_turn(
|
|
128
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
129
|
+
) -> None:
|
|
130
|
+
all_entries = [
|
|
131
|
+
prompt("Fix the runner"),
|
|
132
|
+
tool_call("t1", DESKTOP_TITLE_TOOL),
|
|
133
|
+
tool_result("t1"),
|
|
134
|
+
prompt("Now open the pull request"),
|
|
135
|
+
tool_call("t2", "Bash"),
|
|
136
|
+
tool_result("t2"),
|
|
137
|
+
]
|
|
138
|
+
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
139
|
+
assert DESKTOP_TITLE_TOOL in json.loads(stdout_text)["reason"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_keep_a_title_set_before_a_meta_entry_in_the_same_turn(
|
|
143
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
144
|
+
) -> None:
|
|
145
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
146
|
+
all_entries = [
|
|
147
|
+
prompt("Fix the runner"),
|
|
148
|
+
tool_call("t1", REMOTE_TITLE_TOOL),
|
|
149
|
+
tool_result("t1"),
|
|
150
|
+
meta_prompt("Base directory for this skill: /skills/pr-lifecycle"),
|
|
151
|
+
]
|
|
152
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_should_block_a_desktop_session_once_its_title_tool_is_listed(
|
|
156
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
157
|
+
) -> None:
|
|
158
|
+
all_entries = [deferred_tools("TaskCreate", DESKTOP_TITLE_TOOL), prompt("Fix the runner")]
|
|
159
|
+
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
160
|
+
assert DESKTOP_TITLE_TOOL in json.loads(stdout_text)["reason"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_should_stay_silent_in_a_session_with_no_title_tool(
|
|
164
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
165
|
+
) -> None:
|
|
166
|
+
all_entries = [
|
|
167
|
+
deferred_tools("TaskCreate"),
|
|
168
|
+
prompt("Fix the runner"),
|
|
169
|
+
tool_call("t1", "Bash"),
|
|
170
|
+
tool_result("t1"),
|
|
171
|
+
]
|
|
172
|
+
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_should_stay_silent_on_the_retry_after_a_block(
|
|
176
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
177
|
+
) -> None:
|
|
178
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
179
|
+
assert run_gate(
|
|
180
|
+
monkeypatch, capsys, tmp_path, [prompt("Fix the runner")], stop_hook_active=True
|
|
181
|
+
) == (0, "")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_should_stay_silent_when_the_transcript_is_missing(
|
|
185
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
186
|
+
) -> None:
|
|
187
|
+
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
188
|
+
hook_input = {"hook_event_name": "Stop", "transcript_path": str(tmp_path / "absent.jsonl")}
|
|
189
|
+
monkeypatch.setattr(
|
|
190
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
191
|
+
)
|
|
192
|
+
assert session_title_stop_gate.main() == 0
|
|
193
|
+
assert capsys.readouterr().out == ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_stop_block_reason_returns_none_after_a_successful_title_call() -> None:
|
|
197
|
+
all_entries = [prompt("Fix the runner"), tool_call("t1", REMOTE_TITLE_TOOL), tool_result("t1")]
|
|
198
|
+
assert session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=True) is None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_stop_block_reason_names_the_seen_tool_when_the_turn_has_no_title() -> None:
|
|
202
|
+
all_entries = [
|
|
203
|
+
prompt("Fix the runner"),
|
|
204
|
+
tool_call("t1", DESKTOP_TITLE_TOOL),
|
|
205
|
+
tool_result("t1"),
|
|
206
|
+
prompt("Next"),
|
|
207
|
+
]
|
|
208
|
+
block_reason = session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=False)
|
|
209
|
+
assert block_reason is not None and DESKTOP_TITLE_TOOL in block_reason
|
package/hooks/hooks.json
CHANGED
|
@@ -117,6 +117,16 @@
|
|
|
117
117
|
}
|
|
118
118
|
]
|
|
119
119
|
},
|
|
120
|
+
{
|
|
121
|
+
"matcher": "mcp__.*__set_session_title",
|
|
122
|
+
"hooks": [
|
|
123
|
+
{
|
|
124
|
+
"type": "command",
|
|
125
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_format_gate.py",
|
|
126
|
+
"timeout": 10
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
},
|
|
120
130
|
{
|
|
121
131
|
"matcher": "mcp__hearthbot__start_thread_session",
|
|
122
132
|
"hooks": [
|
|
@@ -251,6 +261,17 @@
|
|
|
251
261
|
]
|
|
252
262
|
}
|
|
253
263
|
],
|
|
264
|
+
"Stop": [
|
|
265
|
+
{
|
|
266
|
+
"hooks": [
|
|
267
|
+
{
|
|
268
|
+
"type": "command",
|
|
269
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_stop_gate.py",
|
|
270
|
+
"timeout": 10
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
],
|
|
254
275
|
"PostToolUse": [
|
|
255
276
|
{
|
|
256
277
|
"matcher": "Write|Edit",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Constants for session_title_format_gate.py and session_title_stop_gate.py."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
TITLE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__set_session_title$")
|
|
6
|
+
REMOTE_SESSION_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_REMOTE_SESSION_ID"
|
|
7
|
+
TITLE_INPUT_KEY = "title"
|
|
8
|
+
TOOL_NAME_KEY = "tool_name"
|
|
9
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
10
|
+
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
11
|
+
STOP_HOOK_ACTIVE_KEY = "stop_hook_active"
|
|
12
|
+
FORMAT_GATE_EVENT_NAME = "PreToolUse"
|
|
13
|
+
STOP_GATE_EVENT_NAME = "Stop"
|
|
14
|
+
ALLOW_EXIT_CODE = 0
|
|
15
|
+
BLOCK_EXIT_CODE = 2
|
|
16
|
+
BLOCK_DECISION = "block"
|
|
17
|
+
DEFERRED_TOOL_ATTACHMENT_TYPES = frozenset({"deferred_tools_delta", "deferred_tools_record"})
|
|
18
|
+
|
|
19
|
+
STATUS_PREFIX_PATTERN = re.compile("^(\U0001f6a9|\u2705|\u23f3)\ufe0f? (?=\\S)")
|
|
20
|
+
MAXIMUM_NAME_LENGTH = 25
|
|
21
|
+
TRAILING_PUNCTUATION = ".!?,;:"
|
|
22
|
+
IDENTIFIER_DIGITS_PATTERN = re.compile(r"\d{3,}")
|
|
23
|
+
|
|
24
|
+
STATUS_PREFIX_MESSAGE = (
|
|
25
|
+
"The title must start with one status emoji and one space:"
|
|
26
|
+
" \U0001f6a9 when the user must act, \u2705 when the work is done,"
|
|
27
|
+
" \u23f3 while work runs and nothing waits on the user."
|
|
28
|
+
)
|
|
29
|
+
SINGLE_LINE_MESSAGE = "The title must be one line."
|
|
30
|
+
NAME_LENGTH_MESSAGE = "The name after the emoji must be 1 to {maximum} characters; it has {length}."
|
|
31
|
+
TRAILING_PUNCTUATION_MESSAGE = "The title must end without punctuation."
|
|
32
|
+
SENTENCE_CASE_MESSAGE = "The name must start with a capital letter (sentence case)."
|
|
33
|
+
IDENTIFIER_MESSAGE = "The name must hold no dates, IDs or numbers of three or more digits."
|
|
34
|
+
BRANCH_NAME_MESSAGE = "The name must hold no branch names or paths (no '/')."
|
|
35
|
+
FORMAT_RETRY_INSTRUCTION = (
|
|
36
|
+
" Call the title tool again with '<emoji> <name>', where the name names"
|
|
37
|
+
" the main change or outcome in concrete nouns, such as"
|
|
38
|
+
" '\u23f3 Broker gate + replay rule'."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
STOP_BLOCK_REASON = (
|
|
42
|
+
"Before you end this turn, set the session title to its current status."
|
|
43
|
+
" Call the session title tool ({tool_name}); when it asks for a session id,"
|
|
44
|
+
" get it from get_session with no session_id."
|
|
45
|
+
" Use the shape '<emoji> <name>'."
|
|
46
|
+
" Emoji: \U0001f6a9 when the user must act (a question, an approval, or a step only the user can do);"
|
|
47
|
+
" \u2705 when the work is done (merged, or nothing left);"
|
|
48
|
+
" \u23f3 while work, CI or a merge queue still runs and nothing waits on the user."
|
|
49
|
+
" Name: 25 characters or fewer, the main change or outcome in concrete nouns,"
|
|
50
|
+
" two parts joined with ' + ', sentence case, no end punctuation,"
|
|
51
|
+
" no dates, IDs, branch names or filler words."
|
|
52
|
+
" Rename it when the scope of the work has changed."
|
|
53
|
+
)
|
|
54
|
+
REMOTE_TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
|
|
55
|
+
UNKNOWN_TITLE_TOOL_NAME = "its name ends in __set_session_title"
|
package/package.json
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Status in the session title
|
|
2
|
+
|
|
3
|
+
**When:** The session has a tool whose name ends in `__set_session_title`.
|
|
4
|
+
|
|
5
|
+
Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
|
|
6
|
+
|
|
7
|
+
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title; `hooks/blocking/session_title_stop_gate.py` blocks a turn end with no title.
|
|
8
|
+
|
|
9
|
+
**Full text:** [`docs/rule-guides/session-title.md`](../docs/rule-guides/session-title.md). Read it for the emoji table and naming rules.
|
|
@@ -132,6 +132,8 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
132
132
|
hooks/blocking/reply_length_gate.py -> exempt
|
|
133
133
|
hooks/blocking/edit_marker_gate.py -> exempt
|
|
134
134
|
hooks/blocking/verify_before_acting.py -> exempt
|
|
135
|
+
hooks/blocking/session_title_format_gate.py -> exempt
|
|
136
|
+
hooks/blocking/session_title_stop_gate.py -> exempt
|
|
135
137
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
136
138
|
|
|
137
139
|
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
@@ -156,6 +158,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
156
158
|
The verify-before-acting hook runs after a mutating call has finished. It
|
|
157
159
|
asks the model to check a hedged claim, and the call it reads stays in place.
|
|
158
160
|
|
|
161
|
+
The session title gates keep the session title in its status format. One
|
|
162
|
+
denies a malformed title, and the other asks for a title before a turn
|
|
163
|
+
ends. They decide the shape of a title and no code or safety policy.
|
|
164
|
+
|
|
159
165
|
Args:
|
|
160
166
|
registered_string: One command, path, script, or entrypoint string.
|
|
161
167
|
"""
|
|
@@ -68,6 +68,8 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
68
68
|
"blocking/reply_length_gate.py",
|
|
69
69
|
"blocking/edit_marker_gate.py",
|
|
70
70
|
"blocking/verify_before_acting.py",
|
|
71
|
+
"blocking/session_title_format_gate.py",
|
|
72
|
+
"blocking/session_title_stop_gate.py",
|
|
71
73
|
)
|
|
72
74
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
73
75
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
@@ -36,12 +36,29 @@ def _log_walk_error(os_error: OSError) -> None:
|
|
|
36
36
|
logging.warning("cannot scan %s -- %s", os_error.filename, os_error.strerror)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _is_older_than(directory_path: str, now: float, min_age_seconds: int) -> bool:
|
|
40
|
+
"""Report whether a directory ctime precedes *now* by more than the minimum age."""
|
|
41
|
+
try:
|
|
42
|
+
raw_ctime = os.path.getctime(directory_path)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return False
|
|
45
|
+
except PermissionError:
|
|
46
|
+
logging.warning("permission denied -- %s", directory_path)
|
|
47
|
+
return False
|
|
48
|
+
except OSError:
|
|
49
|
+
return False
|
|
50
|
+
ctime = min(raw_ctime, now)
|
|
51
|
+
return now - ctime > min_age_seconds
|
|
52
|
+
|
|
53
|
+
|
|
39
54
|
def sweep(root: str, min_age_seconds: int) -> list[str]:
|
|
40
55
|
"""Remove empty directories under *root* older than *min_age_seconds*.
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
Reads the age of every directory before the first removal, because on
|
|
58
|
+
POSIX removing a child directory moves its parent's ctime to the present.
|
|
59
|
+
Then removes bottom-up so nested empty directories are cleaned from the
|
|
60
|
+
leaves inward. os.rmdir decides emptiness and fails harmlessly for a
|
|
61
|
+
directory that still holds an entry.
|
|
45
62
|
|
|
46
63
|
Args:
|
|
47
64
|
root: Root directory to walk; directories below the root are
|
|
@@ -56,35 +73,28 @@ def sweep(root: str, min_age_seconds: int) -> list[str]:
|
|
|
56
73
|
all_removed: list[str] = []
|
|
57
74
|
|
|
58
75
|
now = time.time()
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
all_expired_directories = [
|
|
77
|
+
each_directory_path
|
|
78
|
+
for each_directory_path, _, _ in os.walk(
|
|
79
|
+
root, onerror=_log_walk_error, topdown=False
|
|
80
|
+
)
|
|
81
|
+
if each_directory_path != root
|
|
82
|
+
and _is_older_than(each_directory_path, now, min_age_seconds)
|
|
83
|
+
]
|
|
84
|
+
for each_directory_path in all_expired_directories:
|
|
64
85
|
try:
|
|
65
|
-
|
|
86
|
+
os.rmdir(each_directory_path)
|
|
87
|
+
logging.info("deleted: %s", each_directory_path)
|
|
88
|
+
all_removed.append(each_directory_path)
|
|
66
89
|
except FileNotFoundError:
|
|
67
|
-
|
|
68
|
-
except
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
os.rmdir(each_directory_path)
|
|
77
|
-
logging.info("deleted: %s", each_directory_path)
|
|
78
|
-
all_removed.append(each_directory_path)
|
|
79
|
-
except FileNotFoundError:
|
|
80
|
-
pass
|
|
81
|
-
except OSError as e:
|
|
82
|
-
if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
|
|
83
|
-
logging.warning(
|
|
84
|
-
"could not remove %s -- %s",
|
|
85
|
-
each_directory_path,
|
|
86
|
-
e,
|
|
87
|
-
)
|
|
90
|
+
pass
|
|
91
|
+
except OSError as e:
|
|
92
|
+
if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
|
|
93
|
+
logging.warning(
|
|
94
|
+
"could not remove %s -- %s",
|
|
95
|
+
each_directory_path,
|
|
96
|
+
e,
|
|
97
|
+
)
|
|
88
98
|
|
|
89
99
|
return all_removed
|
|
90
100
|
|
|
@@ -107,6 +107,17 @@ def test_sweep_removes_nested_empty_dirs(tmp_path: Path) -> None:
|
|
|
107
107
|
assert not (tmp_path / "level1").exists()
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def test_sweep_reports_a_nested_chain_removed_leaf_first_in_one_pass(tmp_path: Path) -> None:
|
|
111
|
+
"""sweep reads every age before it removes, so one pass clears the whole chain."""
|
|
112
|
+
outer = tmp_path / "outer"
|
|
113
|
+
inner = outer / "inner"
|
|
114
|
+
inner.mkdir(parents=True)
|
|
115
|
+
|
|
116
|
+
removed = sweep(str(tmp_path), min_age_seconds=0)
|
|
117
|
+
|
|
118
|
+
assert removed == [str(inner), str(outer)]
|
|
119
|
+
|
|
120
|
+
|
|
110
121
|
def test_sweep_removes_only_old_enough_directories(tmp_path: Path) -> None:
|
|
111
122
|
"""sweep does not remove directories newer than the age threshold."""
|
|
112
123
|
young_dir = tmp_path / "young"
|
|
@@ -90,6 +90,17 @@ def test_new_verify_before_acting_registration_is_clean(tmp_path: Path) -> None:
|
|
|
90
90
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def test_new_session_title_gate_registrations_are_clean(tmp_path: Path) -> None:
|
|
94
|
+
current_document = _hook_document(
|
|
95
|
+
[
|
|
96
|
+
"hooks/blocking/session_title_format_gate.py",
|
|
97
|
+
"hooks/blocking/session_title_stop_gate.py",
|
|
98
|
+
],
|
|
99
|
+
'{"hooks": {}}',
|
|
100
|
+
)
|
|
101
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
102
|
+
|
|
103
|
+
|
|
93
104
|
def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
|
|
94
105
|
current_document = _hook_document(
|
|
95
106
|
["hooks/blocking/new.py"],
|