claude-dev-env 8.80.0 → 8.82.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/hooks/blocking/final_reply_gate.py +77 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +13 -3
- package/hooks/blocking/pull_request_proof.py +42 -13
- package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
- package/hooks/blocking/reply_length_gate.py +114 -17
- package/hooks/blocking/test_final_reply_gate.py +94 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_pull_request_proof.py +7 -0
- package/hooks/blocking/test_pull_request_proof_edit_gate.py +199 -0
- package/hooks/blocking/test_reply_length_gate.py +208 -0
- package/hooks/features/conduct-gates.md +7 -3
- package/hooks/features/write-edit-blocking.md +4 -1
- package/hooks/hooks.json +13 -2
- package/hooks/hooks_constants/final_reply_gate_constants.py +10 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +22 -3
- package/hooks/hooks_constants/reply_length_gate_constants.py +56 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +2 -2
- package/hooks/hooks_constants/visual_reply_rules_constants.py +2 -0
- package/hooks/test_visual_reply_rules.py +21 -0
- package/hooks/visual_reply_rules.py +37 -5
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +1 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook that checks the closing chat reply for an unvalidated claim.
|
|
3
|
+
|
|
4
|
+
The reply length gate checks the chat tools. This gate checks the reply the
|
|
5
|
+
session ends its turn with, which reaches the user with no tool call::
|
|
6
|
+
|
|
7
|
+
flag: My guess is one to two hours.
|
|
8
|
+
flag: I didn't confirm this one.
|
|
9
|
+
flag: Run 18239174012 passed. (no tool result shows the run)
|
|
10
|
+
ok: Run 18239174012 took 39 minutes. (a tool result shows the run)
|
|
11
|
+
|
|
12
|
+
It applies the banned words, the unvalidated claim pattern, and the cited
|
|
13
|
+
run, job, check, and pull request numbers from ``reply_length_gate.py``. The
|
|
14
|
+
citation check is skipped when the transcript cannot be read. A block sends
|
|
15
|
+
the reason back so the model rewrites the reply. Claude Code ends the turn
|
|
16
|
+
after eight blocks in a row.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
26
|
+
if hooks_root_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, hooks_root_directory)
|
|
28
|
+
|
|
29
|
+
from reply_length_gate import (
|
|
30
|
+
banned_word_violation,
|
|
31
|
+
configured_banned_words,
|
|
32
|
+
unseen_citation_violation,
|
|
33
|
+
unvalidated_claim_violation,
|
|
34
|
+
)
|
|
35
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
36
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
37
|
+
from hooks_constants.final_reply_gate_constants import (
|
|
38
|
+
ALLOW_EXIT_CODE,
|
|
39
|
+
BLOCK_DECISION,
|
|
40
|
+
DECISION_KEY,
|
|
41
|
+
HOOK_EVENT_NAME,
|
|
42
|
+
LAST_ASSISTANT_MESSAGE_KEY,
|
|
43
|
+
REASON_KEY,
|
|
44
|
+
REWRITE_INSTRUCTION,
|
|
45
|
+
TRANSCRIPT_PATH_KEY,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def final_reply_violation(reply_text: str, transcript_path: object) -> str | None:
|
|
50
|
+
"""Return the block reason for the closing reply, or None when it passes."""
|
|
51
|
+
return (
|
|
52
|
+
banned_word_violation(reply_text, configured_banned_words())
|
|
53
|
+
or unvalidated_claim_violation(reply_text)
|
|
54
|
+
or unseen_citation_violation(reply_text, transcript_path)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
60
|
+
if hook_input is None:
|
|
61
|
+
return ALLOW_EXIT_CODE
|
|
62
|
+
reply_text = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
63
|
+
if not isinstance(reply_text, str):
|
|
64
|
+
return ALLOW_EXIT_CODE
|
|
65
|
+
violation = final_reply_violation(reply_text, hook_input.get(TRANSCRIPT_PATH_KEY))
|
|
66
|
+
if violation is None:
|
|
67
|
+
return ALLOW_EXIT_CODE
|
|
68
|
+
block_reason = violation + REWRITE_INSTRUCTION
|
|
69
|
+
log_hook_block(
|
|
70
|
+
Path(__file__).name, HOOK_EVENT_NAME, block_reason, offending_input_preview=reply_text
|
|
71
|
+
)
|
|
72
|
+
sys.stdout.write(json.dumps({DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}))
|
|
73
|
+
return ALLOW_EXIT_CODE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
|
@@ -261,14 +261,24 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
261
261
|
return None if duplicate_reason is None else _deny(duplicate_reason)
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def
|
|
264
|
+
def transcript_paths(all_payload_fields: dict[str, object]) -> list[str]:
|
|
265
|
+
"""Return each transcript path a hook payload names, with the subagent transcript last.
|
|
266
|
+
|
|
267
|
+
::
|
|
268
|
+
|
|
269
|
+
transcript_path only -> [transcript_path]
|
|
270
|
+
transcript_path and a known agent_id -> [transcript_path, <session>/subagents/agent-<id>.jsonl]
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
274
|
+
"""
|
|
265
275
|
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
266
276
|
paths.append(_subagent_transcript_path(all_payload_fields))
|
|
267
277
|
return list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
|
|
268
278
|
|
|
269
279
|
|
|
270
280
|
def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
271
|
-
readable_paths =
|
|
281
|
+
readable_paths = transcript_paths(all_payload_fields)
|
|
272
282
|
if not readable_paths:
|
|
273
283
|
return False
|
|
274
284
|
statuses = [_scanned_transcript(path, _shows_skill_load) for path in readable_paths]
|
|
@@ -278,7 +288,7 @@ def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
|
278
288
|
def _is_build_eval_missing(all_payload_fields: dict[str, object]) -> bool:
|
|
279
289
|
all_statuses = [
|
|
280
290
|
_scanned_transcript(each_path, _shows_build_eval)
|
|
281
|
-
for each_path in
|
|
291
|
+
for each_path in transcript_paths(all_payload_fields)
|
|
282
292
|
]
|
|
283
293
|
all_read_statuses = [each_status for each_status in all_statuses if each_status is not None]
|
|
284
294
|
return bool(all_read_statuses) and not any(all_read_statuses)
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
|
-
from collections.abc import Mapping
|
|
8
|
+
from collections.abc import Iterator, Mapping
|
|
9
9
|
from typing import NamedTuple
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
@@ -130,15 +130,30 @@ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
|
|
|
130
130
|
return _script_create_arguments(program, all_arguments)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def _shell_command(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
134
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
135
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
136
|
+
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
137
|
+
return None
|
|
138
|
+
return command
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _all_create_argument_lists(command: str) -> Iterator[list[str]]:
|
|
142
|
+
return (
|
|
143
|
+
all_create_arguments
|
|
144
|
+
for each_text in all_wrapped_command_texts(command)
|
|
145
|
+
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
146
|
+
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
133
150
|
def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall]:
|
|
134
151
|
return [
|
|
135
152
|
CreateCall(
|
|
136
153
|
title_from_arguments(all_create_arguments),
|
|
137
154
|
body_from_arguments(all_create_arguments, working_directory),
|
|
138
155
|
)
|
|
139
|
-
for
|
|
140
|
-
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
141
|
-
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
156
|
+
for all_create_arguments in _all_create_argument_lists(command)
|
|
142
157
|
]
|
|
143
158
|
|
|
144
159
|
|
|
@@ -156,15 +171,12 @@ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
156
171
|
head_branch = None if create_fields is None else create_fields.get("head")
|
|
157
172
|
if isinstance(head_branch, str):
|
|
158
173
|
return head_branch
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
174
|
+
command = _shell_command(all_payload_fields)
|
|
175
|
+
if command is None:
|
|
162
176
|
return None
|
|
163
177
|
all_head_flag_values = [
|
|
164
178
|
_head_flag_value(all_create_arguments)
|
|
165
|
-
for
|
|
166
|
-
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
167
|
-
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
179
|
+
for all_create_arguments in _all_create_argument_lists(command)
|
|
168
180
|
]
|
|
169
181
|
return next((each_value for each_value in all_head_flag_values if each_value), None)
|
|
170
182
|
|
|
@@ -180,9 +192,8 @@ def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
|
180
192
|
body if isinstance(body, str) else "",
|
|
181
193
|
)
|
|
182
194
|
]
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
195
|
+
command = _shell_command(all_payload_fields)
|
|
196
|
+
if command is None:
|
|
186
197
|
return []
|
|
187
198
|
working_directory = all_payload_fields.get("cwd")
|
|
188
199
|
return _shell_create_calls(
|
|
@@ -190,6 +201,24 @@ def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
|
190
201
|
)
|
|
191
202
|
|
|
192
203
|
|
|
204
|
+
def is_create_call(all_payload_fields: Mapping[str, object]) -> bool:
|
|
205
|
+
"""Return whether a tool call opens a pull request.
|
|
206
|
+
|
|
207
|
+
::
|
|
208
|
+
|
|
209
|
+
gh pr create --title t --body-file b.md -> True
|
|
210
|
+
mcp__github__create_pull_request -> True
|
|
211
|
+
gh pr edit 7, git push, Read -> False
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
all_payload_fields: A tool name and input, shaped like a PreToolUse payload.
|
|
215
|
+
"""
|
|
216
|
+
command = _shell_command(all_payload_fields)
|
|
217
|
+
return create_tool_fields(all_payload_fields) is not None or (
|
|
218
|
+
command is not None and next(_all_create_argument_lists(command), None) is not None
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
193
222
|
def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
|
|
194
223
|
return [each_call.body for each_call in _create_calls(all_payload_fields)]
|
|
195
224
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Deny an edit to the pull request proof gate while its last deny in the session has no passing create call after it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
11
|
+
if hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, hooks_directory)
|
|
13
|
+
|
|
14
|
+
from blocking.codex_apply_patch import payload_patch_target_paths
|
|
15
|
+
from blocking.pr_lifecycle_skill_gate import transcript_paths
|
|
16
|
+
from blocking.pull_request_proof import is_create_call
|
|
17
|
+
from blocking.step_note_gate import entry_content_blocks
|
|
18
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
19
|
+
HOOK_EVENT_NAME_KEY,
|
|
20
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
21
|
+
PERMISSION_DECISION_KEY,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
24
|
+
DENY_DECISION,
|
|
25
|
+
HOOK_EVENT_NAME,
|
|
26
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
27
|
+
)
|
|
28
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
|
|
29
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
30
|
+
from hooks_constants.pull_request_proof_constants import (
|
|
31
|
+
ALL_PROOF_GATE_DENY_MARKERS,
|
|
32
|
+
ALL_PROOF_GATE_FILE_NAMES,
|
|
33
|
+
GATE_EDIT_BEFORE_ROUTE_REASON,
|
|
34
|
+
LINE_SEPARATOR,
|
|
35
|
+
POSIX_PATH_SEPARATOR,
|
|
36
|
+
WINDOWS_PATH_SEPARATOR,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
39
|
+
from hooks_constants.spawn_readiness_hook_constants import (
|
|
40
|
+
TOOL_RESULT_BLOCK_TYPE,
|
|
41
|
+
TOOL_USE_BLOCK_TYPE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _content_blocks(transcript_line: str) -> list[dict]:
|
|
46
|
+
try:
|
|
47
|
+
entry = json.loads(transcript_line)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
return []
|
|
50
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("message"), dict):
|
|
51
|
+
return []
|
|
52
|
+
return entry_content_blocks(entry)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _result_text(content: object) -> str:
|
|
56
|
+
if isinstance(content, str):
|
|
57
|
+
return content
|
|
58
|
+
if not isinstance(content, list):
|
|
59
|
+
return ""
|
|
60
|
+
return LINE_SEPARATOR.join(
|
|
61
|
+
each_block["text"]
|
|
62
|
+
for each_block in content
|
|
63
|
+
if isinstance(each_block, dict) and isinstance(each_block.get("text"), str)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_proof_gate_deny(all_block_fields: dict[str, object]) -> bool:
|
|
68
|
+
result_text = _result_text(all_block_fields.get("content"))
|
|
69
|
+
return any(each_marker in result_text for each_marker in ALL_PROOF_GATE_DENY_MARKERS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deny_state_after(
|
|
73
|
+
all_block_fields: dict[str, object], all_create_tool_use_ids: set[str], is_deny_open: bool
|
|
74
|
+
) -> bool:
|
|
75
|
+
if all_block_fields.get("type") == TOOL_USE_BLOCK_TYPE:
|
|
76
|
+
tool_use_id = all_block_fields.get("id")
|
|
77
|
+
payload = {"tool_name": all_block_fields.get("name"), "tool_input": all_block_fields.get("input")}
|
|
78
|
+
if isinstance(tool_use_id, str) and is_create_call(payload):
|
|
79
|
+
all_create_tool_use_ids.add(tool_use_id)
|
|
80
|
+
return is_deny_open
|
|
81
|
+
if all_block_fields.get("type") != TOOL_RESULT_BLOCK_TYPE:
|
|
82
|
+
return is_deny_open
|
|
83
|
+
result_tool_use_id = all_block_fields.get("tool_use_id")
|
|
84
|
+
if not isinstance(result_tool_use_id, str) or result_tool_use_id not in all_create_tool_use_ids:
|
|
85
|
+
return is_deny_open
|
|
86
|
+
if all_block_fields.get("is_error") is True:
|
|
87
|
+
return is_deny_open or _is_proof_gate_deny(all_block_fields)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_open_deny(all_transcript_lines: Iterable[str]) -> bool:
|
|
92
|
+
"""Return whether the proof gate's last deny in a transcript has no passing create call after it.
|
|
93
|
+
|
|
94
|
+
::
|
|
95
|
+
|
|
96
|
+
create denied with the proof gate's reason -> True
|
|
97
|
+
create denied, later create returned without an error -> False
|
|
98
|
+
no deny from the proof gate -> False
|
|
99
|
+
|
|
100
|
+
A deny is an errored result of a create call that carries one of the proof gate's guide paths.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
all_transcript_lines: The JSON lines of a session transcript, oldest first.
|
|
104
|
+
"""
|
|
105
|
+
all_create_tool_use_ids: set[str] = set()
|
|
106
|
+
is_deny_open = False
|
|
107
|
+
for each_line in all_transcript_lines:
|
|
108
|
+
for each_block in _content_blocks(each_line):
|
|
109
|
+
is_deny_open = _deny_state_after(each_block, all_create_tool_use_ids, is_deny_open)
|
|
110
|
+
return is_deny_open
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _transcript_has_open_deny(transcript_path: str) -> bool:
|
|
114
|
+
try:
|
|
115
|
+
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
116
|
+
return has_open_deny(transcript)
|
|
117
|
+
except OSError:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _target_paths(all_payload_fields: Mapping[str, object]) -> tuple[str, ...]:
|
|
122
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
123
|
+
if not isinstance(tool_input, dict):
|
|
124
|
+
return ()
|
|
125
|
+
if all_payload_fields.get("tool_name") == APPLY_PATCH_TOOL_NAME:
|
|
126
|
+
try:
|
|
127
|
+
return payload_patch_target_paths(dict(all_payload_fields), tool_input)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return ()
|
|
130
|
+
file_path = tool_input.get("file_path")
|
|
131
|
+
return (file_path,) if isinstance(file_path, str) else ()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _edits_proof_gate(all_payload_fields: Mapping[str, object]) -> bool:
|
|
135
|
+
return any(
|
|
136
|
+
Path(each_path.replace(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR)).name.lower()
|
|
137
|
+
in ALL_PROOF_GATE_FILE_NAMES
|
|
138
|
+
for each_path in _target_paths(all_payload_fields)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def gate_edit_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
143
|
+
"""Return a deny reason for an edit to the proof gate while its last deny is still open, else None.
|
|
144
|
+
|
|
145
|
+
::
|
|
146
|
+
|
|
147
|
+
Edit or apply_patch of a gate file after an open deny -> GATE_EDIT_BEFORE_ROUTE_REASON
|
|
148
|
+
Edit of pull_request_proof.py after a later passing create -> None
|
|
149
|
+
Edit of any other file, or no readable transcript -> None
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
all_payload_fields: The parsed PreToolUse input of a Write, Edit, MultiEdit or apply_patch call.
|
|
153
|
+
"""
|
|
154
|
+
if not _edits_proof_gate(all_payload_fields):
|
|
155
|
+
return None
|
|
156
|
+
if any(_transcript_has_open_deny(each_path) for each_path in transcript_paths(dict(all_payload_fields))):
|
|
157
|
+
return GATE_EDIT_BEFORE_ROUTE_REASON
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _gate_edit_decision(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
162
|
+
reason = gate_edit_reason(all_payload_fields)
|
|
163
|
+
if reason is None:
|
|
164
|
+
return None
|
|
165
|
+
return {
|
|
166
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
167
|
+
HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
|
|
168
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
169
|
+
PERMISSION_DECISION_REASON_KEY: reason,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> int:
|
|
175
|
+
"""Read one Write, Edit, MultiEdit or apply_patch payload and print a deny when it edits the proof gate too soon."""
|
|
176
|
+
return print_decision_from_stdin(_gate_edit_decision)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
|
@@ -33,9 +33,23 @@ The gate uses the ``banned_words`` list in ``~/.claude/reply-banned-words.json``
|
|
|
33
33
|
or in the file that CLAUDE_REPLY_BANNED_WORDS_PATH names. Without a valid
|
|
34
34
|
list there, it uses ALL_DEFAULT_BANNED_WORDS.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
It denies a sentence that guesses, estimates, hedges a duration, or admits
|
|
37
|
+
a check did not run::
|
|
38
|
+
|
|
39
|
+
flag: My guess is one to two hours.
|
|
40
|
+
flag: I didn't confirm this one.
|
|
41
|
+
ok: The last build took 39 minutes.
|
|
42
|
+
|
|
43
|
+
It denies an Actions run, a pull request link, or a run, job, or check id
|
|
44
|
+
that no successful tool result in the transcript shows. A subagent's report
|
|
45
|
+
is the result of its Agent call. With no readable transcript, this check
|
|
46
|
+
is skipped.
|
|
47
|
+
|
|
48
|
+
The banned-word, unvalidated claim, and citation checks also read the prose of a
|
|
49
|
+
decision card: its question, its context, and each option's label and
|
|
50
|
+
consequence. They read an AskUserQuestion card the same way: each
|
|
51
|
+
question, its header, and each option's label and description.
|
|
52
|
+
A card gets no length check.
|
|
39
53
|
|
|
40
54
|
Each non-empty line counts as its own sentence, so a list counts one
|
|
41
55
|
sentence per item. URLs, markdown link targets, inline code spans, and
|
|
@@ -74,6 +88,7 @@ from visual_reply_rules import (
|
|
|
74
88
|
load_rules,
|
|
75
89
|
mode_enabled,
|
|
76
90
|
second_reply_violation,
|
|
91
|
+
successful_result_text,
|
|
77
92
|
visual_violation,
|
|
78
93
|
widget_anchor_violation,
|
|
79
94
|
widget_flowchart_advice,
|
|
@@ -90,6 +105,8 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
90
105
|
ALL_DECISION_OPTION_PROSE_KEYS,
|
|
91
106
|
ALL_DEFAULT_BANNED_WORDS,
|
|
92
107
|
ALL_EVIDENCE_PATTERNS,
|
|
108
|
+
ALL_QUESTION_CARD_PROSE_KEYS,
|
|
109
|
+
ALL_QUESTION_OPTION_PROSE_KEYS,
|
|
93
110
|
ALLOW_EXIT_CODE,
|
|
94
111
|
BANNED_WORD_MESSAGE,
|
|
95
112
|
BANNED_WORD_PART_SEPARATOR,
|
|
@@ -100,10 +117,14 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
100
117
|
BLOCK_EXIT_CODE,
|
|
101
118
|
CAUSAL_CLAIM_PATTERN,
|
|
102
119
|
CARD_TEXT_SEPARATOR,
|
|
120
|
+
CITED_NUMBER_IN_RESULT_TEMPLATE,
|
|
121
|
+
CITED_NUMBER_PATTERN,
|
|
103
122
|
CLAUDE_HOME_DIRECTORY_NAME,
|
|
104
123
|
CONFIG_FILE_ENCODING,
|
|
124
|
+
CURLY_APOSTROPHE,
|
|
105
125
|
DECISION_CARD_OPTIONS_KEY,
|
|
106
126
|
DECISION_CARD_TOOL_NAME,
|
|
127
|
+
DURATION_CLAIM_PATTERN,
|
|
107
128
|
EM_DASH_MESSAGE,
|
|
108
129
|
EM_DASH_PATTERN,
|
|
109
130
|
FENCED_BLOCK_PATTERN,
|
|
@@ -118,10 +139,13 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
118
139
|
MAXIMUM_WORDS_PER_SENTENCE,
|
|
119
140
|
MID_SENTENCE_COLON_MESSAGE,
|
|
120
141
|
MID_SENTENCE_COLON_PATTERN,
|
|
142
|
+
QUESTION_CARD_QUESTIONS_KEY,
|
|
143
|
+
QUESTION_CARD_TOOL_NAME,
|
|
121
144
|
RETRY_INSTRUCTION,
|
|
122
145
|
SENTENCE_END_PATTERN,
|
|
123
146
|
SENTENCE_PREVIEW_SUFFIX,
|
|
124
147
|
SENTENCE_PREVIEW_WORD_COUNT,
|
|
148
|
+
STRAIGHT_APOSTROPHE,
|
|
125
149
|
TEXT_KEY,
|
|
126
150
|
TOO_MANY_SENTENCES_MESSAGE,
|
|
127
151
|
TOOL_INPUT_KEY,
|
|
@@ -129,7 +153,10 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
129
153
|
TRANSCRIPT_PATH_KEY,
|
|
130
154
|
UNLINKED_PULL_REQUEST_MESSAGE,
|
|
131
155
|
UNLINKED_PULL_REQUEST_PATTERN,
|
|
156
|
+
UNSEEN_CITATION_MESSAGE,
|
|
132
157
|
UNSOURCED_CAUSE_MESSAGE,
|
|
158
|
+
UNVALIDATED_CLAIM_MESSAGE,
|
|
159
|
+
UNVALIDATED_CLAIM_PATTERN,
|
|
133
160
|
URL_PATTERN,
|
|
134
161
|
WORD_PATTERN,
|
|
135
162
|
WORD_SEPARATOR,
|
|
@@ -146,12 +173,9 @@ def countable_text(reply_text: str) -> str:
|
|
|
146
173
|
|
|
147
174
|
def sentence_word_lists(reply_text: str) -> list[list[str]]:
|
|
148
175
|
"""Split the text into sentences, one per line or terminal mark, as word lists."""
|
|
149
|
-
|
|
150
|
-
each_sentence
|
|
151
|
-
for each_line in LINE_BREAK_PATTERN.split(countable_text(reply_text))
|
|
152
|
-
for each_sentence in SENTENCE_END_PATTERN.split(each_line)
|
|
176
|
+
all_word_lists = [
|
|
177
|
+
WORD_PATTERN.findall(each_sentence) for each_sentence in prose_sentences(countable_text(reply_text))
|
|
153
178
|
]
|
|
154
|
-
all_word_lists = [WORD_PATTERN.findall(each_sentence) for each_sentence in all_sentence_texts]
|
|
155
179
|
return [each_word_list for each_word_list in all_word_lists if each_word_list]
|
|
156
180
|
|
|
157
181
|
|
|
@@ -187,14 +211,18 @@ def em_dash_violation(reply_text: str) -> str | None:
|
|
|
187
211
|
return EM_DASH_MESSAGE
|
|
188
212
|
|
|
189
213
|
|
|
190
|
-
def
|
|
191
|
-
"""
|
|
192
|
-
|
|
193
|
-
each_sentence
|
|
214
|
+
def prose_sentences(reply_text: str) -> list[str]:
|
|
215
|
+
"""Split the text outside fenced blocks into sentences, one per line or terminal mark."""
|
|
216
|
+
return [
|
|
217
|
+
each_sentence.strip()
|
|
194
218
|
for each_line in LINE_BREAK_PATTERN.split(FENCED_BLOCK_PATTERN.sub(" ", reply_text))
|
|
195
219
|
for each_sentence in SENTENCE_END_PATTERN.split(each_line)
|
|
196
220
|
]
|
|
197
|
-
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def unsourced_cause_violation(reply_text: str) -> str | None:
|
|
224
|
+
"""Return the deny reason for the first causal sentence that cites no evidence itself, or None."""
|
|
225
|
+
for each_sentence in prose_sentences(reply_text):
|
|
198
226
|
is_causal = CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
|
|
199
227
|
has_evidence = any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
|
|
200
228
|
if is_causal and not has_evidence:
|
|
@@ -254,6 +282,45 @@ def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) ->
|
|
|
254
282
|
return None
|
|
255
283
|
|
|
256
284
|
|
|
285
|
+
def unvalidated_claim_violation(reply_text: str) -> str | None:
|
|
286
|
+
"""Return the deny reason quoting the first sentence that guesses or admits a skipped check, or None.
|
|
287
|
+
|
|
288
|
+
A duration counts as a guess unless its sentence cites the run, job, check,
|
|
289
|
+
or pull request that measured it::
|
|
290
|
+
|
|
291
|
+
flag: It should take about 40 minutes.
|
|
292
|
+
ok: The last queue build took 39 minutes, run 18239174012.
|
|
293
|
+
"""
|
|
294
|
+
for each_sentence in prose_sentences(reply_text):
|
|
295
|
+
prose_text = countable_text(each_sentence).replace(CURLY_APOSTROPHE, STRAIGHT_APOSTROPHE)
|
|
296
|
+
is_guess = UNVALIDATED_CLAIM_PATTERN.search(prose_text) is not None
|
|
297
|
+
is_uncited_duration = (
|
|
298
|
+
DURATION_CLAIM_PATTERN.search(prose_text) is not None
|
|
299
|
+
and CITED_NUMBER_PATTERN.search(each_sentence) is None
|
|
300
|
+
)
|
|
301
|
+
if is_guess or is_uncited_duration:
|
|
302
|
+
return UNVALIDATED_CLAIM_MESSAGE.format(claim=each_sentence)
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def unseen_citation_violation(reply_text: str, transcript_path: object) -> str | None:
|
|
307
|
+
"""Return the deny reason for the first cited run, job, check, or pull request no tool result shows.
|
|
308
|
+
|
|
309
|
+
Returns None when the text cites nothing or the transcript cannot be read.
|
|
310
|
+
"""
|
|
311
|
+
all_citations = list(CITED_NUMBER_PATTERN.finditer(FENCED_BLOCK_PATTERN.sub(" ", reply_text)))
|
|
312
|
+
if not all_citations:
|
|
313
|
+
return None
|
|
314
|
+
result_text = successful_result_text(transcript_path)
|
|
315
|
+
if result_text is None:
|
|
316
|
+
return None
|
|
317
|
+
for each_match in all_citations:
|
|
318
|
+
cited_number = next(each_group for each_group in each_match.groups() if each_group)
|
|
319
|
+
if re.search(CITED_NUMBER_IN_RESULT_TEMPLATE.format(number=cited_number), result_text) is None:
|
|
320
|
+
return UNSEEN_CITATION_MESSAGE.format(citation=each_match.group(0))
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
257
324
|
def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
258
325
|
"""Collect the prose fields of a decision card: question, context, and each option's text."""
|
|
259
326
|
all_options = all_card_input.get(DECISION_CARD_OPTIONS_KEY)
|
|
@@ -271,6 +338,24 @@ def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
|
271
338
|
]
|
|
272
339
|
|
|
273
340
|
|
|
341
|
+
def all_question_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
342
|
+
"""Collect the prose fields of a question card: each question, its header, and each option's text."""
|
|
343
|
+
all_questions = all_card_input.get(QUESTION_CARD_QUESTIONS_KEY)
|
|
344
|
+
all_question_fields: list[object] = []
|
|
345
|
+
for each_question in all_questions if isinstance(all_questions, list) else []:
|
|
346
|
+
if not isinstance(each_question, dict):
|
|
347
|
+
continue
|
|
348
|
+
all_question_fields.extend(each_question.get(each_key) for each_key in ALL_QUESTION_CARD_PROSE_KEYS)
|
|
349
|
+
all_options = each_question.get(DECISION_CARD_OPTIONS_KEY)
|
|
350
|
+
all_question_fields.extend(
|
|
351
|
+
each_option.get(each_key)
|
|
352
|
+
for each_option in (all_options if isinstance(all_options, list) else [])
|
|
353
|
+
if isinstance(each_option, dict)
|
|
354
|
+
for each_key in ALL_QUESTION_OPTION_PROSE_KEYS
|
|
355
|
+
)
|
|
356
|
+
return [each_field for each_field in all_question_fields if isinstance(each_field, str)]
|
|
357
|
+
|
|
358
|
+
|
|
274
359
|
def active_mode_rules() -> VisualReplyRules | None:
|
|
275
360
|
"""Return the visual reply rules when the mode is on and the rule file parses."""
|
|
276
361
|
if not mode_enabled(Path.home() / CLAUDE_HOME_DIRECTORY_NAME):
|
|
@@ -280,7 +365,10 @@ def active_mode_rules() -> VisualReplyRules | None:
|
|
|
280
365
|
|
|
281
366
|
def card_violation(card_text: str, mode_rules: VisualReplyRules | None) -> str | None:
|
|
282
367
|
"""Return the deny reason for a decision card's prose, or None."""
|
|
283
|
-
return
|
|
368
|
+
return (
|
|
369
|
+
banned_word_violation(card_text, configured_banned_words())
|
|
370
|
+
or unvalidated_claim_violation(card_text)
|
|
371
|
+
) or (
|
|
284
372
|
None if mode_rules is None else abbreviation_violation(prose_outside_links(card_text), mode_rules)
|
|
285
373
|
)
|
|
286
374
|
|
|
@@ -296,6 +384,8 @@ def reply_violation(
|
|
|
296
384
|
or unlinked_pull_request_violation(reply_text)
|
|
297
385
|
or unsourced_cause_violation(reply_text)
|
|
298
386
|
or banned_word_violation(reply_text, configured_banned_words())
|
|
387
|
+
or unvalidated_claim_violation(reply_text)
|
|
388
|
+
or unseen_citation_violation(reply_text, transcript_path)
|
|
299
389
|
)
|
|
300
390
|
if violation is not None or mode_rules is None:
|
|
301
391
|
return violation
|
|
@@ -316,9 +406,16 @@ def tool_violation(
|
|
|
316
406
|
anchor_violation = widget_anchor_violation(tool_name, all_tool_input)
|
|
317
407
|
if anchor_violation is not None:
|
|
318
408
|
return anchor_violation, str(all_tool_input)
|
|
319
|
-
if tool_name
|
|
320
|
-
|
|
321
|
-
|
|
409
|
+
if tool_name in (DECISION_CARD_TOOL_NAME, QUESTION_CARD_TOOL_NAME):
|
|
410
|
+
all_texts = (
|
|
411
|
+
all_card_texts(all_tool_input)
|
|
412
|
+
if tool_name == DECISION_CARD_TOOL_NAME
|
|
413
|
+
else all_question_card_texts(all_tool_input)
|
|
414
|
+
)
|
|
415
|
+
card_text = CARD_TEXT_SEPARATOR.join(all_texts)
|
|
416
|
+
violation = card_violation(card_text, mode_rules) or unseen_citation_violation(
|
|
417
|
+
card_text, transcript_path
|
|
418
|
+
)
|
|
322
419
|
return None if violation is None else (violation, card_text)
|
|
323
420
|
if tool_name not in ALL_CHECKED_TOOL_NAMES:
|
|
324
421
|
return None
|