claude-dev-env 8.74.1 → 8.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/correction/SKILL.md +19 -16
- package/.agents/skills/correction/references/filing.md +1 -1
- package/.agents/skills/correction/scripts/correction_task_card.py +194 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/config/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/config/constants.py +38 -0
- package/.agents/skills/correction/scripts/test_correction_task_card.py +142 -0
- package/bin/cde.mjs +1 -1
- package/bin/cde.test.mjs +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/bin/merge_profile_settings.mjs +2 -3
- package/bin/merge_profile_settings.test.mjs +5 -5
- package/commands/fix-followups.md +5 -2
- package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
- package/docs/rule-guides/paired-test-coverage.md +1 -1
- package/docs/rule-guides/session-title.md +3 -3
- package/docs/templates/html-plan/README.md +1 -1
- package/hooks/features/conduct-gates.md +0 -3
- package/hooks/features/session-context.md +1 -1
- package/hooks/followup_ledger.py +106 -108
- package/hooks/followup_ledger_maintenance.py +127 -0
- package/hooks/hooks.json +0 -11
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
- package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
- package/hooks/hooks_constants/session_title_constants.py +1 -29
- package/hooks/session/artifact_template_pointer.py +5 -4
- package/hooks/session/test_artifact_template_pointer.py +13 -0
- package/hooks/test_followup_ledger.py +78 -59
- package/hooks/test_followup_ledger_maintenance.py +107 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
- package/scripts/followup_cli.py +11 -12
- package/scripts/policy_lint/adapter_configuration.py +0 -1
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/repository_checks/config/constants.py +0 -6
- package/scripts/repository_checks/runner.py +0 -7
- package/scripts/test_followup_cli.py +71 -7
- package/scripts/tests/test_adapter_configuration.py +2 -5
- package/scripts/tests/test_cde_lint.py +3 -4
- package/scripts/tests/test_repository_policy.py +1 -3
- package/hooks/blocking/session_title_stop_gate.py +0 -217
- package/hooks/blocking/test_session_title_stop_gate.py +0 -295
- package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
- package/scripts/tests/test_followup_ledger_duplicates.py +0 -58
|
@@ -1,217 +0,0 @@
|
|
|
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 title tool named anywhere in the
|
|
13
|
-
transcript, and either no remote session id in the environment or a
|
|
14
|
-
deferred tool listing that names other remote-server tools and leaves
|
|
15
|
-
the title tool out, or an earlier Stop block the session never met;
|
|
16
|
-
- the transcript cannot be read.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import json
|
|
22
|
-
import os
|
|
23
|
-
import sys
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
27
|
-
if hooks_root_directory not in sys.path:
|
|
28
|
-
sys.path.insert(0, hooks_root_directory)
|
|
29
|
-
|
|
30
|
-
from hooks_constants.hook_block_logger import log_hook_block
|
|
31
|
-
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
32
|
-
from hooks_constants.session_title_constants import (
|
|
33
|
-
ALLOW_EXIT_CODE,
|
|
34
|
-
BLOCK_DECISION,
|
|
35
|
-
DEFERRED_TOOL_ATTACHMENT_TYPES,
|
|
36
|
-
HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE,
|
|
37
|
-
REMOTE_SERVER_TOOL_PREFIX,
|
|
38
|
-
REMOTE_SESSION_ENVIRONMENT_VARIABLE,
|
|
39
|
-
REMOTE_TITLE_TOOL_NAME,
|
|
40
|
-
STOP_BLOCK_REASON,
|
|
41
|
-
STOP_GATE_EVENT_NAME,
|
|
42
|
-
STOP_HOOK_ACTIVE_KEY,
|
|
43
|
-
TITLE_TOOL_NAME_PATTERN,
|
|
44
|
-
TRANSCRIPT_PATH_KEY,
|
|
45
|
-
UNKNOWN_TITLE_TOOL_NAME,
|
|
46
|
-
)
|
|
47
|
-
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _content_blocks(all_entry_fields: dict[str, object]) -> list[dict[str, object]]:
|
|
51
|
-
message = all_entry_fields.get("message")
|
|
52
|
-
content = message.get("content") if isinstance(message, dict) else None
|
|
53
|
-
if not isinstance(content, list):
|
|
54
|
-
return []
|
|
55
|
-
return [each_block for each_block in content if isinstance(each_block, dict)]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
|
|
59
|
-
if all_entry_fields.get("type") != "user" or all_entry_fields.get("isMeta") is True:
|
|
60
|
-
return False
|
|
61
|
-
message = all_entry_fields.get("message")
|
|
62
|
-
if isinstance(message, dict) and isinstance(message.get("content"), str):
|
|
63
|
-
return True
|
|
64
|
-
return any(each_block.get("type") == "text" for each_block in _content_blocks(all_entry_fields))
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _title_tool_names_in(all_entry_fields: dict[str, object]) -> list[str]:
|
|
68
|
-
all_names: list[str] = []
|
|
69
|
-
if all_entry_fields.get("type") == "assistant":
|
|
70
|
-
all_names = [
|
|
71
|
-
str(each_block.get("name"))
|
|
72
|
-
for each_block in _content_blocks(all_entry_fields)
|
|
73
|
-
if each_block.get("type") == "tool_use"
|
|
74
|
-
]
|
|
75
|
-
attachment = all_entry_fields.get("attachment")
|
|
76
|
-
if isinstance(attachment, dict) and attachment.get("type") in DEFERRED_TOOL_ATTACHMENT_TYPES:
|
|
77
|
-
all_names += [str(each_name) for each_name in attachment.get("addedNames") or []]
|
|
78
|
-
all_names += [
|
|
79
|
-
str(each_record.get("name"))
|
|
80
|
-
for each_record in attachment.get("entries") or []
|
|
81
|
-
if isinstance(each_record, dict)
|
|
82
|
-
]
|
|
83
|
-
return [each_name for each_name in all_names if TITLE_TOOL_NAME_PATTERN.match(each_name)]
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _defers_remote_server_tools(all_entry_fields: dict[str, object]) -> bool:
|
|
87
|
-
attachment = all_entry_fields.get("attachment")
|
|
88
|
-
if not isinstance(attachment, dict) or attachment.get("type") not in DEFERRED_TOOL_ATTACHMENT_TYPES:
|
|
89
|
-
return False
|
|
90
|
-
all_names = [str(each_name) for each_name in attachment.get("addedNames") or []]
|
|
91
|
-
all_names += [
|
|
92
|
-
str(each_record.get("name"))
|
|
93
|
-
for each_record in attachment.get("entries") or []
|
|
94
|
-
if isinstance(each_record, dict)
|
|
95
|
-
]
|
|
96
|
-
return any(each_name.startswith(REMOTE_SERVER_TOOL_PREFIX) for each_name in all_names)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _is_stop_block(all_entry_fields: dict[str, object]) -> bool:
|
|
100
|
-
attachment = all_entry_fields.get("attachment")
|
|
101
|
-
return (
|
|
102
|
-
isinstance(attachment, dict)
|
|
103
|
-
and attachment.get("type") == HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE
|
|
104
|
-
and attachment.get("hookEvent") == STOP_GATE_EVENT_NAME
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _is_title_call(entry_type: object, all_block_fields: dict[str, object]) -> bool:
|
|
109
|
-
return (
|
|
110
|
-
entry_type == "assistant"
|
|
111
|
-
and all_block_fields.get("type") == "tool_use"
|
|
112
|
-
and bool(TITLE_TOOL_NAME_PATTERN.match(str(all_block_fields.get("name"))))
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _title_set_since(all_turn_entries: list[dict[str, object]]) -> bool:
|
|
117
|
-
all_entry_blocks = [
|
|
118
|
-
(each_entry.get("type"), each_block)
|
|
119
|
-
for each_entry in all_turn_entries
|
|
120
|
-
for each_block in _content_blocks(each_entry)
|
|
121
|
-
]
|
|
122
|
-
all_title_call_ids = {
|
|
123
|
-
str(each_block.get("id"))
|
|
124
|
-
for each_entry_type, each_block in all_entry_blocks
|
|
125
|
-
if _is_title_call(each_entry_type, each_block)
|
|
126
|
-
}
|
|
127
|
-
return any(
|
|
128
|
-
each_block.get("type") == "tool_result"
|
|
129
|
-
and str(each_block.get("tool_use_id")) in all_title_call_ids
|
|
130
|
-
and not each_block.get("is_error")
|
|
131
|
-
for _, each_block in all_entry_blocks
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _turn_start_index(all_entries: list[dict[str, object]]) -> int:
|
|
136
|
-
return max(
|
|
137
|
-
(
|
|
138
|
-
each_index
|
|
139
|
-
for each_index, each_entry in enumerate(all_entries)
|
|
140
|
-
if _is_user_prompt(each_entry)
|
|
141
|
-
),
|
|
142
|
-
default=0,
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: bool) -> str | None:
|
|
147
|
-
"""Return the block reason when this turn has not set the title, else None.
|
|
148
|
-
|
|
149
|
-
::
|
|
150
|
-
|
|
151
|
-
prompt -> set_session_title -> ok result => None
|
|
152
|
-
prompt -> work, no title call => block reason
|
|
153
|
-
no title tool anywhere, not remote => None
|
|
154
|
-
remote, deferred remote tools lack title => None
|
|
155
|
-
remote, earlier Stop block never met => None
|
|
156
|
-
remote, remote tools load directly => block reason
|
|
157
|
-
|
|
158
|
-
Args:
|
|
159
|
-
all_entries: The transcript entries, oldest first.
|
|
160
|
-
is_remote_session: True when the session runs in a cloud session.
|
|
161
|
-
|
|
162
|
-
Returns:
|
|
163
|
-
The reason to send back to the model, or None to let the turn end.
|
|
164
|
-
"""
|
|
165
|
-
all_seen_tool_names = [
|
|
166
|
-
each_name for each_entry in all_entries for each_name in _title_tool_names_in(each_entry)
|
|
167
|
-
]
|
|
168
|
-
if not all_seen_tool_names and (
|
|
169
|
-
not is_remote_session
|
|
170
|
-
or any(_defers_remote_server_tools(each) or _is_stop_block(each) for each in all_entries)
|
|
171
|
-
):
|
|
172
|
-
return None
|
|
173
|
-
if _title_set_since(all_entries[_turn_start_index(all_entries) :]):
|
|
174
|
-
return None
|
|
175
|
-
if all_seen_tool_names:
|
|
176
|
-
return STOP_BLOCK_REASON.format(tool_name=all_seen_tool_names[-1])
|
|
177
|
-
tool_name = REMOTE_TITLE_TOOL_NAME if is_remote_session else UNKNOWN_TITLE_TOOL_NAME
|
|
178
|
-
return STOP_BLOCK_REASON.format(tool_name=tool_name)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def _parsed_entry(line: str) -> dict[str, object] | None:
|
|
182
|
-
try:
|
|
183
|
-
parsed_entry = json.loads(line)
|
|
184
|
-
except json.JSONDecodeError:
|
|
185
|
-
return None
|
|
186
|
-
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _read_entries(transcript_path: str) -> list[dict[str, object]]:
|
|
190
|
-
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
191
|
-
all_lines = transcript.readlines()
|
|
192
|
-
all_parsed_entries = [_parsed_entry(each_line) for each_line in all_lines]
|
|
193
|
-
return [each_entry for each_entry in all_parsed_entries if each_entry is not None]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def main() -> int:
|
|
197
|
-
hook_input = read_hook_input_dictionary_from_stdin()
|
|
198
|
-
if hook_input is None or hook_input.get(STOP_HOOK_ACTIVE_KEY):
|
|
199
|
-
return ALLOW_EXIT_CODE
|
|
200
|
-
transcript_path = hook_input.get(TRANSCRIPT_PATH_KEY)
|
|
201
|
-
if not isinstance(transcript_path, str) or not transcript_path:
|
|
202
|
-
return ALLOW_EXIT_CODE
|
|
203
|
-
try:
|
|
204
|
-
all_entries = _read_entries(transcript_path)
|
|
205
|
-
except OSError:
|
|
206
|
-
return ALLOW_EXIT_CODE
|
|
207
|
-
is_remote_session = bool(os.environ.get(REMOTE_SESSION_ENVIRONMENT_VARIABLE))
|
|
208
|
-
block_reason = stop_block_reason(all_entries, is_remote_session)
|
|
209
|
-
if block_reason is None:
|
|
210
|
-
return ALLOW_EXIT_CODE
|
|
211
|
-
log_hook_block(Path(__file__).name, STOP_GATE_EVENT_NAME, block_reason)
|
|
212
|
-
sys.stdout.write(json.dumps({"decision": BLOCK_DECISION, "reason": block_reason}))
|
|
213
|
-
return ALLOW_EXIT_CODE
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if __name__ == "__main__":
|
|
217
|
-
sys.exit(main())
|
|
@@ -1,295 +0,0 @@
|
|
|
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_in_a_remote_session_whose_tool_list_lacks_the_title_tool(
|
|
176
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
177
|
-
) -> None:
|
|
178
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
179
|
-
all_entries = [
|
|
180
|
-
deferred_tools("TaskCreate", "mcp__claude-code-remote__get_session"),
|
|
181
|
-
prompt("Fix the runner"),
|
|
182
|
-
tool_call("t1", "Bash"),
|
|
183
|
-
tool_result("t1"),
|
|
184
|
-
]
|
|
185
|
-
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def test_should_block_a_remote_session_whose_deferred_list_holds_no_remote_server_tool(
|
|
189
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
190
|
-
) -> None:
|
|
191
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
192
|
-
all_entries = [
|
|
193
|
-
deferred_tools("TaskCreate", "WebFetch"),
|
|
194
|
-
prompt("Fix the runner"),
|
|
195
|
-
tool_call("t1", "Bash"),
|
|
196
|
-
tool_result("t1"),
|
|
197
|
-
]
|
|
198
|
-
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
199
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def test_should_block_a_remote_session_whose_tool_list_names_the_title_tool(
|
|
203
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
204
|
-
) -> None:
|
|
205
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
206
|
-
all_entries = [
|
|
207
|
-
deferred_tools("TaskCreate", REMOTE_TITLE_TOOL),
|
|
208
|
-
prompt("Fix the runner"),
|
|
209
|
-
tool_call("t1", "Bash"),
|
|
210
|
-
tool_result("t1"),
|
|
211
|
-
]
|
|
212
|
-
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
213
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def earlier_stop_block() -> dict[str, object]:
|
|
217
|
-
return {
|
|
218
|
-
"type": "attachment",
|
|
219
|
-
"attachment": {"type": "hook_blocking_error", "hookEvent": "Stop"},
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def test_should_stay_silent_in_a_remote_session_after_an_unmet_earlier_block(
|
|
224
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
225
|
-
) -> None:
|
|
226
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
227
|
-
all_entries = [
|
|
228
|
-
prompt("Fix the runner"),
|
|
229
|
-
earlier_stop_block(),
|
|
230
|
-
prompt("Next task"),
|
|
231
|
-
tool_call("t1", "Bash"),
|
|
232
|
-
tool_result("t1"),
|
|
233
|
-
]
|
|
234
|
-
assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def test_should_block_after_an_earlier_block_once_the_title_tool_is_listed(
|
|
238
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
239
|
-
) -> None:
|
|
240
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
241
|
-
all_entries = [
|
|
242
|
-
deferred_tools(REMOTE_TITLE_TOOL),
|
|
243
|
-
prompt("Fix the runner"),
|
|
244
|
-
earlier_stop_block(),
|
|
245
|
-
prompt("Next task"),
|
|
246
|
-
tool_call("t1", "Bash"),
|
|
247
|
-
tool_result("t1"),
|
|
248
|
-
]
|
|
249
|
-
_, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
|
|
250
|
-
assert json.loads(stdout_text)["decision"] == "block"
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def test_should_stay_silent_on_the_retry_after_a_block(
|
|
254
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
255
|
-
) -> None:
|
|
256
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
257
|
-
assert run_gate(
|
|
258
|
-
monkeypatch, capsys, tmp_path, [prompt("Fix the runner")], stop_hook_active=True
|
|
259
|
-
) == (0, "")
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def test_should_stay_silent_when_the_transcript_is_missing(
|
|
263
|
-
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
264
|
-
) -> None:
|
|
265
|
-
monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
|
|
266
|
-
hook_input = {"hook_event_name": "Stop", "transcript_path": str(tmp_path / "absent.jsonl")}
|
|
267
|
-
monkeypatch.setattr(
|
|
268
|
-
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
269
|
-
)
|
|
270
|
-
assert session_title_stop_gate.main() == 0
|
|
271
|
-
assert capsys.readouterr().out == ""
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def test_stop_block_reason_returns_none_after_a_successful_title_call() -> None:
|
|
275
|
-
all_entries = [prompt("Fix the runner"), tool_call("t1", REMOTE_TITLE_TOOL), tool_result("t1")]
|
|
276
|
-
assert session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=True) is None
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def test_stop_block_reason_names_the_seen_tool_when_the_turn_has_no_title() -> None:
|
|
280
|
-
all_entries = [
|
|
281
|
-
prompt("Fix the runner"),
|
|
282
|
-
tool_call("t1", DESKTOP_TITLE_TOOL),
|
|
283
|
-
tool_result("t1"),
|
|
284
|
-
prompt("Next"),
|
|
285
|
-
]
|
|
286
|
-
block_reason = session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=False)
|
|
287
|
-
assert block_reason is not None and DESKTOP_TITLE_TOOL in block_reason
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def test_stop_block_reason_tells_the_model_to_set_the_title_in_silence() -> None:
|
|
291
|
-
all_entries = [prompt("Fix the runner"), tool_call("t1", REMOTE_TITLE_TOOL), prompt("Next")]
|
|
292
|
-
block_reason = session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=True)
|
|
293
|
-
assert block_reason is not None
|
|
294
|
-
assert "Say nothing to the user about the title" in block_reason
|
|
295
|
-
assert "end with no text" in block_reason
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Committed follow-up ledger check for exact repeated lines.
|
|
2
|
-
|
|
3
|
-
GitHub's server-side merge ignores ``merge=union``, so repeated lines arrive
|
|
4
|
-
only through a local union merge. This check stops them at the merge queue.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from collections.abc import Sequence
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from types import ModuleType
|
|
12
|
-
|
|
13
|
-
from policy_lint.config import constants as policy_constants
|
|
14
|
-
|
|
15
|
-
from repository_checks.config import constants as repository_constants
|
|
16
|
-
from repository_checks.hook_modules import load_hooks_module
|
|
17
|
-
from repository_checks.models import RepositoryFinding
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def collect_followup_ledger_duplicate_findings(
|
|
21
|
-
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
22
|
-
) -> list[RepositoryFinding]:
|
|
23
|
-
"""Return a finding when the tracked ledger repeats a line.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
repository_root: Git repository root.
|
|
27
|
-
all_tracked_paths: Repository-relative tracked paths.
|
|
28
|
-
|
|
29
|
-
Returns:
|
|
30
|
-
One finding naming the repeated-line count, or no findings.
|
|
31
|
-
"""
|
|
32
|
-
ledger = load_hooks_module(repository_constants.FOLLOWUP_LEDGER_MODULE_NAME)
|
|
33
|
-
ledger_path = ledger.followup_ledger_path(repository_root)
|
|
34
|
-
relative_path = ledger_path.relative_to(repository_root).as_posix()
|
|
35
|
-
if relative_path not in all_tracked_paths:
|
|
36
|
-
return []
|
|
37
|
-
repeated_line_count = _repeated_line_count(ledger, ledger_path)
|
|
38
|
-
if not repeated_line_count:
|
|
39
|
-
return []
|
|
40
|
-
return [
|
|
41
|
-
RepositoryFinding(
|
|
42
|
-
repository_constants.CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
|
|
43
|
-
relative_path,
|
|
44
|
-
repository_constants.FOLLOWUP_LEDGER_DUPLICATE_MESSAGE_TEMPLATE.format(
|
|
45
|
-
repeated_line_count=repeated_line_count
|
|
46
|
-
),
|
|
47
|
-
)
|
|
48
|
-
]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _repeated_line_count(ledger: ModuleType, ledger_path: Path) -> int:
|
|
52
|
-
all_ledger_lines = ledger_path.read_text(
|
|
53
|
-
encoding=policy_constants.UTF8_ENCODING
|
|
54
|
-
).splitlines()
|
|
55
|
-
return len(all_ledger_lines) - len(ledger.deduplicated_lines(all_ledger_lines))
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for the committed follow-up ledger duplicate check."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
_TESTS_DIRECTORY = Path(__file__).resolve().parent
|
|
9
|
-
if str(_TESTS_DIRECTORY) not in sys.path:
|
|
10
|
-
sys.path.insert(0, str(_TESTS_DIRECTORY))
|
|
11
|
-
|
|
12
|
-
from repository_checks.config.constants import (
|
|
13
|
-
CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
|
|
14
|
-
FINDINGS_EXIT_CODE,
|
|
15
|
-
SUCCESS_EXIT_CODE,
|
|
16
|
-
)
|
|
17
|
-
from repository_policy_test_support import (
|
|
18
|
-
commit_tracked_files,
|
|
19
|
-
initialize_repository,
|
|
20
|
-
run_policy,
|
|
21
|
-
write_text,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
_LEDGER_RELATIVE_PATH = ".claude/followups/smells.jsonl"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_should_flag_a_committed_ledger_with_a_repeated_line(tmp_path: Path) -> None:
|
|
28
|
-
repository_root = tmp_path / "repo"
|
|
29
|
-
initialize_repository(repository_root)
|
|
30
|
-
write_text(
|
|
31
|
-
repository_root / _LEDGER_RELATIVE_PATH,
|
|
32
|
-
'{"rule_id": "a"}\n{"rule_id": "b"}\n{"rule_id": "a"}\n',
|
|
33
|
-
)
|
|
34
|
-
commit_tracked_files(repository_root)
|
|
35
|
-
|
|
36
|
-
exit_code, stdout_text, _stderr_text = run_policy(repository_root)
|
|
37
|
-
|
|
38
|
-
assert exit_code == FINDINGS_EXIT_CODE
|
|
39
|
-
assert (
|
|
40
|
-
f"{CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES}: {_LEDGER_RELATIVE_PATH}:"
|
|
41
|
-
in stdout_text
|
|
42
|
-
)
|
|
43
|
-
assert "cde followup dedupe" in stdout_text
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_should_pass_a_committed_ledger_without_repeats(tmp_path: Path) -> None:
|
|
47
|
-
repository_root = tmp_path / "repo"
|
|
48
|
-
initialize_repository(repository_root)
|
|
49
|
-
write_text(
|
|
50
|
-
repository_root / _LEDGER_RELATIVE_PATH,
|
|
51
|
-
'{"rule_id": "a"}\n{"rule_id": "b"}\n',
|
|
52
|
-
)
|
|
53
|
-
commit_tracked_files(repository_root)
|
|
54
|
-
|
|
55
|
-
exit_code, stdout_text, _stderr_text = run_policy(repository_root)
|
|
56
|
-
|
|
57
|
-
assert exit_code == SUCCESS_EXIT_CODE
|
|
58
|
-
assert stdout_text == ""
|