claude-dev-env 8.84.0 → 8.85.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/bin/install.prompt-context.test.mjs +2 -1
- package/hooks/blocking/chat_reply_gate.py +246 -0
- package/hooks/blocking/inventory_intent_records/records.py +2 -5
- package/hooks/blocking/pull_request_proof.py +133 -17
- package/hooks/blocking/test_chat_reply_gate.py +224 -0
- package/hooks/blocking/test_pull_request_proof.py +144 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +21 -0
- package/hooks/hooks_constants/chat_reply_gate_constants.py +58 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +18 -5
- package/hooks/hooks_constants/session_file_id.py +17 -0
- package/hooks/hooks_constants/test_session_file_id.py +17 -0
- package/hooks/observability/session_file_edit_tracker.py +2 -5
- package/hooks/session/session_edit_tracker_cleanup.py +2 -5
- package/package.json +1 -1
- package/scripts/conftest.py +22 -13
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +12 -0
- package/scripts/policy_lint/adapter_configuration.py +3 -1
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +0 -6
- package/scripts/test_account_broker_support.py +6 -7
- package/scripts/test_broker_state_isolation.py +7 -8
- package/scripts/test_conftest.py +54 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -35,11 +35,12 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
|
|
|
35
35
|
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
|
|
36
36
|
|
|
37
37
|
const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
|
|
38
|
-
assert.equal(userPromptHooks.length,
|
|
38
|
+
assert.equal(userPromptHooks.length, 5);
|
|
39
39
|
assert.equal(userPromptHooks[0].command, userCommand);
|
|
40
40
|
assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
|
|
41
41
|
assert.match(userPromptHooks[2].command, /session\/deepen_first_prompt\.py$/);
|
|
42
42
|
assert.match(userPromptHooks[3].command, /lifecycle\/nested_project_hooks\.py$/);
|
|
43
|
+
assert.match(userPromptHooks[4].command, /blocking\/chat_reply_gate\.py$/);
|
|
43
44
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
44
45
|
|
|
45
46
|
const firstMerge = JSON.stringify(settings);
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hook that shows the user one short chat reply per message.
|
|
3
|
+
|
|
4
|
+
``reply_length_gate.py`` checks only the chat tools that post to Slack.
|
|
5
|
+
A reply typed straight into the chat is no tool call, so this hook runs
|
|
6
|
+
the same checks on it::
|
|
7
|
+
|
|
8
|
+
UserPromptSubmit -> additionalContext states the rules
|
|
9
|
+
MessageDisplay, batch before the last -> hold the text, show nothing yet
|
|
10
|
+
MessageDisplay, last batch, passes -> show the whole message
|
|
11
|
+
MessageDisplay, last batch, fails -> show a draft from a small model that passes
|
|
12
|
+
|
|
13
|
+
A Stop hook would see the reply only after it is on screen, so each block
|
|
14
|
+
would add a second reply under the first. MessageDisplay changes the text
|
|
15
|
+
before it renders. The transcript keeps the original, so the next
|
|
16
|
+
UserPromptSubmit tells the agent the text the user saw.
|
|
17
|
+
|
|
18
|
+
The draft comes from ``claude -p`` on DEFAULT_DRAFTER_MODEL with no setting
|
|
19
|
+
sources and no tools. The draft must pass ``reply_violation``; after
|
|
20
|
+
MAXIMUM_DRAFT_ATTEMPTS failed drafts, the original shows.
|
|
21
|
+
|
|
22
|
+
The checks come from ``reply_length_gate.reply_violation``: at most
|
|
23
|
+
MAXIMUM_SENTENCE_COUNT sentences of at most MAXIMUM_WORDS_PER_SENTENCE
|
|
24
|
+
words, no mid-sentence colon, no em dash, linked pull requests, a source
|
|
25
|
+
for each causal claim, and no banned word.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
40
|
+
if hooks_root_directory not in sys.path:
|
|
41
|
+
sys.path.insert(0, hooks_root_directory)
|
|
42
|
+
blocking_directory = str(Path(__file__).resolve().parent)
|
|
43
|
+
if blocking_directory not in sys.path:
|
|
44
|
+
sys.path.insert(0, blocking_directory)
|
|
45
|
+
|
|
46
|
+
from reply_length_gate import reply_violation
|
|
47
|
+
from hooks_constants.chat_reply_gate_constants import (
|
|
48
|
+
CLAUDE_SESSION_ID_ENV_VAR,
|
|
49
|
+
DEFAULT_DRAFTER_MODEL,
|
|
50
|
+
DELTA_KEY,
|
|
51
|
+
DISPLAY_CONTENT_KEY,
|
|
52
|
+
DRAFTER_GUARD_ENV_VAR,
|
|
53
|
+
DRAFTER_MODEL_ENV_VAR,
|
|
54
|
+
DRAFTER_REQUEST_TEMPLATE,
|
|
55
|
+
DRAFTER_ERROR_KEY,
|
|
56
|
+
DRAFTER_EXECUTABLE_NAME,
|
|
57
|
+
DRAFTER_RESULT_KEY,
|
|
58
|
+
DRAFTER_RULES,
|
|
59
|
+
DRAFTER_TIMEOUT_SECONDS,
|
|
60
|
+
FINAL_KEY,
|
|
61
|
+
HELD_TEXT_FILE_PREFIX,
|
|
62
|
+
HOOK_EVENT_NAME_INPUT_KEY,
|
|
63
|
+
MAXIMUM_DRAFT_ATTEMPTS,
|
|
64
|
+
MESSAGE_DISPLAY_EVENT_NAME,
|
|
65
|
+
MESSAGE_ID_KEY,
|
|
66
|
+
REPLY_LIMIT_REMINDER,
|
|
67
|
+
SESSION_END_EVENT_NAME,
|
|
68
|
+
SESSION_ID_KEY,
|
|
69
|
+
SHOWN_DRAFT_FILE_PREFIX,
|
|
70
|
+
SHOWN_DRAFT_PREFIX,
|
|
71
|
+
STATE_FILE_ENCODING,
|
|
72
|
+
STATE_FILE_SUFFIX,
|
|
73
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
74
|
+
)
|
|
75
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
76
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
77
|
+
HOOK_EVENT_NAME_KEY,
|
|
78
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
79
|
+
)
|
|
80
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
81
|
+
from hooks_constants.reply_length_gate_constants import (
|
|
82
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
83
|
+
ALLOW_EXIT_CODE,
|
|
84
|
+
)
|
|
85
|
+
from hooks_constants.session_file_id import session_file_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def state_file_path(file_prefix: str, state_key: object) -> Path:
|
|
89
|
+
"""Return the temp file that holds one message's held text or one session's shown draft."""
|
|
90
|
+
file_name = f"{file_prefix}{session_file_id(state_key)}{STATE_FILE_SUFFIX}"
|
|
91
|
+
return Path(tempfile.gettempdir()) / file_name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_state_text(state_path: Path) -> str:
|
|
95
|
+
"""Return the stored text, or an empty string when the file is absent or unreadable."""
|
|
96
|
+
try:
|
|
97
|
+
return state_path.read_text(encoding=STATE_FILE_ENCODING)
|
|
98
|
+
except OSError:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_state_text(state_path: Path, state_text: str) -> None:
|
|
103
|
+
"""Store the text; a failed write leaves the shown text unchanged."""
|
|
104
|
+
try:
|
|
105
|
+
state_path.write_text(state_text, encoding=STATE_FILE_ENCODING)
|
|
106
|
+
except OSError:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def drafter_command() -> list[str]:
|
|
111
|
+
"""Return the headless claude command that drafts with no settings and no tools."""
|
|
112
|
+
return [
|
|
113
|
+
shutil.which(DRAFTER_EXECUTABLE_NAME) or DRAFTER_EXECUTABLE_NAME,
|
|
114
|
+
"-p",
|
|
115
|
+
"--model",
|
|
116
|
+
os.environ.get(DRAFTER_MODEL_ENV_VAR, DEFAULT_DRAFTER_MODEL),
|
|
117
|
+
"--setting-sources",
|
|
118
|
+
"",
|
|
119
|
+
"--tools",
|
|
120
|
+
"",
|
|
121
|
+
"--no-session-persistence",
|
|
122
|
+
"--session-id",
|
|
123
|
+
str(uuid.uuid4()),
|
|
124
|
+
"--output-format",
|
|
125
|
+
"json",
|
|
126
|
+
"--system-prompt",
|
|
127
|
+
DRAFTER_RULES,
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def drafter_environment() -> dict[str, str]:
|
|
132
|
+
"""Return the drafter's environment, marked so this hook exits inside it."""
|
|
133
|
+
environment = dict(os.environ)
|
|
134
|
+
environment[DRAFTER_GUARD_ENV_VAR] = "1"
|
|
135
|
+
environment.pop(CLAUDE_SESSION_ID_ENV_VAR, None)
|
|
136
|
+
return environment
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ask_drafter(reply_text: str, violation: str) -> str | None:
|
|
140
|
+
"""Return one draft from the small model, or None when the call fails."""
|
|
141
|
+
try:
|
|
142
|
+
completed = subprocess.run(
|
|
143
|
+
drafter_command(),
|
|
144
|
+
input=DRAFTER_REQUEST_TEMPLATE.format(violation=violation, reply_text=reply_text),
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
timeout=DRAFTER_TIMEOUT_SECONDS,
|
|
148
|
+
env=drafter_environment(),
|
|
149
|
+
check=False,
|
|
150
|
+
)
|
|
151
|
+
drafter_output = json.loads(completed.stdout)
|
|
152
|
+
draft_text = drafter_output.get(DRAFTER_RESULT_KEY)
|
|
153
|
+
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, AttributeError):
|
|
154
|
+
return None
|
|
155
|
+
if drafter_output.get(DRAFTER_ERROR_KEY) is True:
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(draft_text, str) or not draft_text.strip():
|
|
158
|
+
return None
|
|
159
|
+
return draft_text.strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def compliant_draft(reply_text: str, violation: str) -> str | None:
|
|
163
|
+
"""Return a draft that passes ``reply_violation``, or None after MAXIMUM_DRAFT_ATTEMPTS."""
|
|
164
|
+
current_violation = violation
|
|
165
|
+
for _ in range(MAXIMUM_DRAFT_ATTEMPTS):
|
|
166
|
+
draft_text = ask_drafter(reply_text, current_violation)
|
|
167
|
+
if draft_text is None:
|
|
168
|
+
continue
|
|
169
|
+
draft_violation = reply_violation(draft_text, None, None)
|
|
170
|
+
if draft_violation is None:
|
|
171
|
+
return draft_text
|
|
172
|
+
current_violation = draft_violation
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def shown_text(all_hook_input: dict[str, object]) -> str | None:
|
|
177
|
+
"""Return the text to show for this batch, or None to show the batch as it is."""
|
|
178
|
+
message_id = all_hook_input.get(MESSAGE_ID_KEY)
|
|
179
|
+
if not isinstance(message_id, str) or not message_id:
|
|
180
|
+
return None
|
|
181
|
+
held_path = state_file_path(HELD_TEXT_FILE_PREFIX, message_id)
|
|
182
|
+
held_text = read_state_text(held_path)
|
|
183
|
+
message_text = held_text + str(all_hook_input.get(DELTA_KEY) or "")
|
|
184
|
+
if not all_hook_input.get(FINAL_KEY):
|
|
185
|
+
write_state_text(held_path, message_text)
|
|
186
|
+
return ""
|
|
187
|
+
held_path.unlink(missing_ok=True)
|
|
188
|
+
shown_draft_path = state_file_path(SHOWN_DRAFT_FILE_PREFIX, all_hook_input.get(SESSION_ID_KEY))
|
|
189
|
+
violation = reply_violation(message_text, None, None)
|
|
190
|
+
draft_text = None if violation is None else compliant_draft(message_text, violation)
|
|
191
|
+
if violation is None or draft_text is None:
|
|
192
|
+
shown_draft_path.unlink(missing_ok=True)
|
|
193
|
+
return message_text if held_text else None
|
|
194
|
+
write_state_text(shown_draft_path, draft_text)
|
|
195
|
+
log_hook_block(
|
|
196
|
+
Path(__file__).name,
|
|
197
|
+
MESSAGE_DISPLAY_EVENT_NAME,
|
|
198
|
+
violation,
|
|
199
|
+
offending_input_preview=message_text,
|
|
200
|
+
)
|
|
201
|
+
return draft_text
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def write_hook_output(event_name: str, output_key: str, output_text: str) -> None:
|
|
205
|
+
"""Print one hookSpecificOutput field for the event."""
|
|
206
|
+
hook_event_output = {HOOK_EVENT_NAME_KEY: event_name, output_key: output_text}
|
|
207
|
+
sys.stdout.write(json.dumps({HOOK_SPECIFIC_OUTPUT_KEY: hook_event_output}))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def reply_limit_reminder(session_id: object) -> str:
|
|
211
|
+
"""Return the reply rules, plus the draft the user saw in place of the last reply."""
|
|
212
|
+
shown_draft_path = state_file_path(SHOWN_DRAFT_FILE_PREFIX, session_id)
|
|
213
|
+
shown_draft = read_state_text(shown_draft_path)
|
|
214
|
+
if not shown_draft:
|
|
215
|
+
return REPLY_LIMIT_REMINDER
|
|
216
|
+
shown_draft_path.unlink(missing_ok=True)
|
|
217
|
+
return REPLY_LIMIT_REMINDER + SHOWN_DRAFT_PREFIX + shown_draft
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main() -> int:
|
|
221
|
+
if os.environ.get(DRAFTER_GUARD_ENV_VAR):
|
|
222
|
+
return ALLOW_EXIT_CODE
|
|
223
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
224
|
+
if hook_input is None:
|
|
225
|
+
return ALLOW_EXIT_CODE
|
|
226
|
+
event_name = hook_input.get(HOOK_EVENT_NAME_INPUT_KEY)
|
|
227
|
+
if event_name == USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
228
|
+
write_hook_output(
|
|
229
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
230
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
231
|
+
reply_limit_reminder(hook_input.get(SESSION_ID_KEY)),
|
|
232
|
+
)
|
|
233
|
+
return ALLOW_EXIT_CODE
|
|
234
|
+
if event_name == SESSION_END_EVENT_NAME:
|
|
235
|
+
state_file_path(SHOWN_DRAFT_FILE_PREFIX, hook_input.get(SESSION_ID_KEY)).unlink(missing_ok=True)
|
|
236
|
+
return ALLOW_EXIT_CODE
|
|
237
|
+
if event_name != MESSAGE_DISPLAY_EVENT_NAME:
|
|
238
|
+
return ALLOW_EXIT_CODE
|
|
239
|
+
display_text = shown_text(hook_input)
|
|
240
|
+
if display_text is not None:
|
|
241
|
+
write_hook_output(MESSAGE_DISPLAY_EVENT_NAME, DISPLAY_CONTENT_KEY, display_text)
|
|
242
|
+
return ALLOW_EXIT_CODE
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
sys.exit(main())
|
|
@@ -33,11 +33,10 @@ from inventory_intent_records.config.intent_records_constants import (
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
from hooks_constants.session_edit_stage_gate_constants import (
|
|
36
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
37
36
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
38
|
-
STATE_FILE_DEFAULT_SESSION_ID,
|
|
39
37
|
STATE_FILE_JSON_INDENT_SPACES,
|
|
40
38
|
)
|
|
39
|
+
from hooks_constants.session_file_id import session_file_id
|
|
41
40
|
|
|
42
41
|
|
|
43
42
|
def _intent_file_path(session_id: str) -> Path:
|
|
@@ -50,9 +49,7 @@ def _intent_file_path(session_id: str) -> Path:
|
|
|
50
49
|
Returns:
|
|
51
50
|
The absolute path of this session's intent-records file.
|
|
52
51
|
"""
|
|
53
|
-
|
|
54
|
-
effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
55
|
-
file_name = f"{INTENT_FILE_PREFIX}{effective_session_id}{INTENT_FILE_SUFFIX}"
|
|
52
|
+
file_name = f"{INTENT_FILE_PREFIX}{session_file_id(session_id)}{INTENT_FILE_SUFFIX}"
|
|
56
53
|
return Path(tempfile.gettempdir()) / file_name
|
|
57
54
|
|
|
58
55
|
|
|
@@ -21,10 +21,20 @@ from hooks_constants.pull_request_proof_constants import (
|
|
|
21
21
|
ALL_VISIBLE_FILE_SUFFIXES,
|
|
22
22
|
CHANGED_FILES_SEPARATOR,
|
|
23
23
|
COMMAND_MARKER,
|
|
24
|
+
ALL_BASE_FLAGS,
|
|
24
25
|
ALL_DEFAULT_BRANCH_REFERENCES,
|
|
25
26
|
ALL_HEAD_FLAGS,
|
|
27
|
+
ALL_REPOSITORY_FLAGS,
|
|
28
|
+
BASE_FLAG_ASSIGNMENT_PREFIX,
|
|
29
|
+
COMPARE_ENDPOINT_TEMPLATE,
|
|
30
|
+
COMPARE_FILE_LIST_LIMIT,
|
|
31
|
+
COMPARE_FILE_NAMES_QUERY,
|
|
32
|
+
DEFAULT_BRANCH_QUERY,
|
|
33
|
+
GH_TIMEOUT_SECONDS,
|
|
26
34
|
HEAD_FLAG_ASSIGNMENT_PREFIX,
|
|
27
35
|
REMOTE_BRANCH_PREFIX,
|
|
36
|
+
REPOSITORY_ENDPOINT_TEMPLATE,
|
|
37
|
+
REPOSITORY_FLAG_ASSIGNMENT_PREFIX,
|
|
28
38
|
UNREADABLE_CHANGES_REASON,
|
|
29
39
|
EVAL_HEADING_PATTERN,
|
|
30
40
|
EXISTING_WORK_HEADING_PATTERN,
|
|
@@ -174,28 +184,55 @@ def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall
|
|
|
174
184
|
]
|
|
175
185
|
|
|
176
186
|
|
|
177
|
-
def
|
|
187
|
+
def _flag_value(all_create_arguments: list[str], all_flags: frozenset[str], assignment_prefix: str) -> str | None:
|
|
178
188
|
for each_position, each_argument in enumerate(all_create_arguments):
|
|
179
|
-
if each_argument in
|
|
180
|
-
return all_create_arguments[each_position + 1]
|
|
181
|
-
if each_argument.startswith(
|
|
182
|
-
return each_argument[len(
|
|
189
|
+
if each_argument in all_flags and each_position + 1 < len(all_create_arguments):
|
|
190
|
+
return unquoted_token(all_create_arguments[each_position + 1])
|
|
191
|
+
if each_argument.startswith(assignment_prefix):
|
|
192
|
+
return unquoted_token(each_argument[len(assignment_prefix) :])
|
|
183
193
|
return None
|
|
184
194
|
|
|
185
195
|
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if isinstance(head_branch, str):
|
|
190
|
-
return head_branch
|
|
196
|
+
def _shell_flag_value(
|
|
197
|
+
all_payload_fields: Mapping[str, object], all_flags: frozenset[str], assignment_prefix: str
|
|
198
|
+
) -> str | None:
|
|
191
199
|
command = _shell_command(all_payload_fields)
|
|
192
200
|
if command is None:
|
|
193
201
|
return None
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
all_flag_values = [
|
|
203
|
+
_flag_value(all_create_arguments, all_flags, assignment_prefix)
|
|
196
204
|
for all_create_arguments in _all_create_argument_lists(command)
|
|
197
205
|
]
|
|
198
|
-
return next((each_value for each_value in
|
|
206
|
+
return next((each_value for each_value in all_flag_values if each_value), None)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _named_create_field(
|
|
210
|
+
all_payload_fields: Mapping[str, object], field_name: str, all_flags: frozenset[str], assignment_prefix: str
|
|
211
|
+
) -> str | None:
|
|
212
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
213
|
+
field_value = None if create_fields is None else create_fields.get(field_name)
|
|
214
|
+
if isinstance(field_value, str) and field_value:
|
|
215
|
+
return field_value
|
|
216
|
+
return _shell_flag_value(all_payload_fields, all_flags, assignment_prefix)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
220
|
+
return _named_create_field(all_payload_fields, "head", ALL_HEAD_FLAGS, HEAD_FLAG_ASSIGNMENT_PREFIX)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _named_base_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
224
|
+
return _named_create_field(all_payload_fields, "base", ALL_BASE_FLAGS, BASE_FLAG_ASSIGNMENT_PREFIX)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _named_repository(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
228
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
229
|
+
if create_fields is not None:
|
|
230
|
+
owner = create_fields.get("owner")
|
|
231
|
+
repository_name = create_fields.get("repo")
|
|
232
|
+
if isinstance(owner, str) and owner and isinstance(repository_name, str) and repository_name:
|
|
233
|
+
return f"{owner}/{repository_name}"
|
|
234
|
+
return None
|
|
235
|
+
return _shell_flag_value(all_payload_fields, ALL_REPOSITORY_FLAGS, REPOSITORY_FLAG_ASSIGNMENT_PREFIX)
|
|
199
236
|
|
|
200
237
|
|
|
201
238
|
def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
@@ -485,9 +522,83 @@ def visible_changed_files(working_directory: str, head_branch: object) -> list[s
|
|
|
485
522
|
all_changed_paths = _git_lines(working_directory, "diff", "--name-only", merge_base, head_reference)
|
|
486
523
|
if all_changed_paths is None:
|
|
487
524
|
return None
|
|
525
|
+
return _visible_paths(all_changed_paths)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _visible_paths(all_changed_paths: list[str]) -> list[str]:
|
|
488
529
|
return [each_path for each_path in all_changed_paths if each_path.lower().endswith(ALL_VISIBLE_FILE_SUFFIXES)]
|
|
489
530
|
|
|
490
531
|
|
|
532
|
+
def _gh_api_lines(endpoint: str, query: str) -> list[str] | None:
|
|
533
|
+
try:
|
|
534
|
+
completed = subprocess.run(
|
|
535
|
+
["gh", "api", endpoint, "--jq", query],
|
|
536
|
+
capture_output=True,
|
|
537
|
+
text=True,
|
|
538
|
+
timeout=GH_TIMEOUT_SECONDS,
|
|
539
|
+
check=False,
|
|
540
|
+
)
|
|
541
|
+
except (OSError, subprocess.SubprocessError):
|
|
542
|
+
return None
|
|
543
|
+
return completed.stdout.splitlines() if completed.returncode == 0 else None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _compare_base_branch(repository: str, base_branch: str | None) -> str | None:
|
|
547
|
+
if base_branch:
|
|
548
|
+
return base_branch
|
|
549
|
+
all_default_branch_lines = _gh_api_lines(
|
|
550
|
+
REPOSITORY_ENDPOINT_TEMPLATE.format(repository=repository), DEFAULT_BRANCH_QUERY
|
|
551
|
+
)
|
|
552
|
+
if not all_default_branch_lines:
|
|
553
|
+
return None
|
|
554
|
+
return all_default_branch_lines[0]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _visible_compare_paths(repository: str, base_branch: str, head_branch: str) -> list[str] | None:
|
|
558
|
+
all_changed_paths = _gh_api_lines(
|
|
559
|
+
COMPARE_ENDPOINT_TEMPLATE.format(
|
|
560
|
+
repository=repository, base_branch=base_branch, head_branch=head_branch
|
|
561
|
+
),
|
|
562
|
+
COMPARE_FILE_NAMES_QUERY,
|
|
563
|
+
)
|
|
564
|
+
if all_changed_paths is None:
|
|
565
|
+
return None
|
|
566
|
+
all_visible_paths = _visible_paths(all_changed_paths)
|
|
567
|
+
if all_visible_paths or len(all_changed_paths) < COMPARE_FILE_LIST_LIMIT:
|
|
568
|
+
return all_visible_paths
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def remote_visible_changed_files(
|
|
573
|
+
repository: str | None, base_branch: str | None, head_branch: str | None
|
|
574
|
+
) -> list[str] | None:
|
|
575
|
+
"""Return the changed files people see, read from the GitHub compare API, or None when it cannot tell.
|
|
576
|
+
|
|
577
|
+
::
|
|
578
|
+
|
|
579
|
+
compare lists tool.py -> []
|
|
580
|
+
compare lists web/page.html -> ["web/page.html"]
|
|
581
|
+
compare returns 300 paths and none are visible -> None
|
|
582
|
+
repository or head missing -> None
|
|
583
|
+
|
|
584
|
+
The base is the named base branch, else the repository's default branch.
|
|
585
|
+
A page of 300 paths is the compare API's file cap. A visible file on that
|
|
586
|
+
page is returned. A full page with none is None, because a later file can
|
|
587
|
+
still be one people see.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
repository: The owner/name the create call names, or None.
|
|
591
|
+
base_branch: The base branch the create call names, or None.
|
|
592
|
+
head_branch: The head branch the create call names, or None.
|
|
593
|
+
"""
|
|
594
|
+
if not repository or not head_branch:
|
|
595
|
+
return None
|
|
596
|
+
resolved_base_branch = _compare_base_branch(repository, base_branch)
|
|
597
|
+
if resolved_base_branch is None:
|
|
598
|
+
return None
|
|
599
|
+
return _visible_compare_paths(repository, resolved_base_branch, head_branch)
|
|
600
|
+
|
|
601
|
+
|
|
491
602
|
def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
492
603
|
"""Return a deny reason when a pull request changing visible files shows no picture, else None.
|
|
493
604
|
|
|
@@ -496,7 +607,9 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
496
607
|
branch changes page.html, proof has  -> None
|
|
497
608
|
branch changes page.html, proof quotes only command output -> MISSING_LOOK_REASON
|
|
498
609
|
branch changes only .py files -> None
|
|
499
|
-
git cannot read the branch,
|
|
610
|
+
git cannot read the branch, compare API lists only .py files -> None
|
|
611
|
+
git cannot read the branch, compare API returns 300 .py files -> UNREADABLE_CHANGES_REASON
|
|
612
|
+
neither git nor the compare API can read it, no picture -> UNREADABLE_CHANGES_REASON
|
|
500
613
|
|
|
501
614
|
Args:
|
|
502
615
|
all_payload_fields: The parsed PreToolUse input.
|
|
@@ -505,9 +618,12 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
505
618
|
if not all_bodies:
|
|
506
619
|
return None
|
|
507
620
|
working_directory = all_payload_fields.get("cwd")
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
621
|
+
head_branch = _named_head_branch(all_payload_fields)
|
|
622
|
+
all_visible_files = visible_changed_files(str(working_directory or ""), head_branch)
|
|
623
|
+
if all_visible_files is None:
|
|
624
|
+
all_visible_files = remote_visible_changed_files(
|
|
625
|
+
_named_repository(all_payload_fields), _named_base_branch(all_payload_fields), head_branch
|
|
626
|
+
)
|
|
511
627
|
if all_visible_files == []:
|
|
512
628
|
return None
|
|
513
629
|
for each_body in all_bodies:
|