claude-dev-env 4.2.0 → 4.3.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/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +90 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -8
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Extract durable GitHub post body text for privacy checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from blocking._gh_body_arg_utils import (
|
|
9
|
+
all_body_flags,
|
|
10
|
+
body_file_flag,
|
|
11
|
+
body_file_short_flag,
|
|
12
|
+
count_extra_tokens_to_skip_for_split_quoted_value,
|
|
13
|
+
get_logical_first_line,
|
|
14
|
+
is_unresolvable_shell_value,
|
|
15
|
+
match_body_file_equals_prefix,
|
|
16
|
+
match_body_flag_equals_prefix,
|
|
17
|
+
strip_surrounding_quotes,
|
|
18
|
+
)
|
|
19
|
+
from hooks_constants.gh_post_body_texts_constants import (
|
|
20
|
+
ALL_GH_POST_SUBCOMMANDS,
|
|
21
|
+
ALL_MCP_BODY_PARAM_NAMES,
|
|
22
|
+
BODY_FILE_ENCODING,
|
|
23
|
+
BODY_FLAG_WITH_VALUE_STEP,
|
|
24
|
+
GH_COMMAND_NAME,
|
|
25
|
+
MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT,
|
|
26
|
+
TOKEN_JOIN_SEPARATOR,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_environment_assignment(token: str) -> bool:
|
|
31
|
+
equals_index = token.find("=")
|
|
32
|
+
if equals_index <= 0:
|
|
33
|
+
return False
|
|
34
|
+
name_part = token[:equals_index]
|
|
35
|
+
if not (name_part[0].isalpha() or name_part[0] == "_"):
|
|
36
|
+
return False
|
|
37
|
+
return all(each_character.isalnum() or each_character == "_" for each_character in name_part)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tokens_name_gh_post_command(all_command_tokens: list[str]) -> bool:
|
|
41
|
+
command_index = 0
|
|
42
|
+
while command_index < len(all_command_tokens) and _is_environment_assignment(
|
|
43
|
+
all_command_tokens[command_index]
|
|
44
|
+
):
|
|
45
|
+
command_index += 1
|
|
46
|
+
if command_index >= len(all_command_tokens):
|
|
47
|
+
return False
|
|
48
|
+
if all_command_tokens[command_index] != GH_COMMAND_NAME:
|
|
49
|
+
return False
|
|
50
|
+
remaining_tokens = all_command_tokens[command_index + 1 :]
|
|
51
|
+
if len(remaining_tokens) < MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT:
|
|
52
|
+
return False
|
|
53
|
+
post_noun, post_verb = remaining_tokens[:MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT]
|
|
54
|
+
return post_verb in ALL_GH_POST_SUBCOMMANDS.get(post_noun, frozenset())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _reassemble_split_quoted_value(
|
|
58
|
+
value_token: str,
|
|
59
|
+
all_following_tokens: list[str],
|
|
60
|
+
) -> tuple[str, int]:
|
|
61
|
+
extra_token_count = count_extra_tokens_to_skip_for_split_quoted_value(
|
|
62
|
+
all_following_tokens, value_token
|
|
63
|
+
)
|
|
64
|
+
if not extra_token_count:
|
|
65
|
+
return strip_surrounding_quotes(value_token), 0
|
|
66
|
+
joined_value = (
|
|
67
|
+
value_token
|
|
68
|
+
+ TOKEN_JOIN_SEPARATOR
|
|
69
|
+
+ TOKEN_JOIN_SEPARATOR.join(all_following_tokens[:extra_token_count])
|
|
70
|
+
)
|
|
71
|
+
return strip_surrounding_quotes(joined_value), extra_token_count
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extract_flag_value_at(
|
|
75
|
+
all_command_tokens: list[str],
|
|
76
|
+
token_index: int,
|
|
77
|
+
) -> tuple[bool, str, int] | None:
|
|
78
|
+
current_token = all_command_tokens[token_index]
|
|
79
|
+
following_tokens = all_command_tokens[token_index + 1 :]
|
|
80
|
+
body_prefix = match_body_flag_equals_prefix(current_token)
|
|
81
|
+
if body_prefix is not None:
|
|
82
|
+
value, extra_count = _reassemble_split_quoted_value(
|
|
83
|
+
current_token[len(body_prefix) :], following_tokens
|
|
84
|
+
)
|
|
85
|
+
return False, value, token_index + 1 + extra_count
|
|
86
|
+
body_file_prefix = match_body_file_equals_prefix(current_token)
|
|
87
|
+
if body_file_prefix is not None:
|
|
88
|
+
value, extra_count = _reassemble_split_quoted_value(
|
|
89
|
+
current_token[len(body_file_prefix) :], following_tokens
|
|
90
|
+
)
|
|
91
|
+
return True, value, token_index + 1 + extra_count
|
|
92
|
+
if current_token in all_body_flags and following_tokens:
|
|
93
|
+
value, extra_count = _reassemble_split_quoted_value(
|
|
94
|
+
following_tokens[0], following_tokens[1:]
|
|
95
|
+
)
|
|
96
|
+
return False, value, token_index + BODY_FLAG_WITH_VALUE_STEP + extra_count
|
|
97
|
+
if current_token in (body_file_flag, body_file_short_flag) and following_tokens:
|
|
98
|
+
value, extra_count = _reassemble_split_quoted_value(
|
|
99
|
+
following_tokens[0], following_tokens[1:]
|
|
100
|
+
)
|
|
101
|
+
return True, value, token_index + BODY_FLAG_WITH_VALUE_STEP + extra_count
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _collect_body_flag_values(
|
|
106
|
+
all_command_tokens: list[str],
|
|
107
|
+
) -> tuple[list[str], list[str]]:
|
|
108
|
+
all_inline_bodies: list[str] = []
|
|
109
|
+
all_body_file_paths: list[str] = []
|
|
110
|
+
token_index = 0
|
|
111
|
+
while token_index < len(all_command_tokens):
|
|
112
|
+
extraction = _extract_flag_value_at(all_command_tokens, token_index)
|
|
113
|
+
if extraction is None:
|
|
114
|
+
token_index += 1
|
|
115
|
+
continue
|
|
116
|
+
is_body_file, value, token_index = extraction
|
|
117
|
+
if is_body_file:
|
|
118
|
+
all_body_file_paths.append(value)
|
|
119
|
+
else:
|
|
120
|
+
all_inline_bodies.append(value)
|
|
121
|
+
return all_inline_bodies, all_body_file_paths
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_body_file(
|
|
125
|
+
body_file_path: str,
|
|
126
|
+
working_directory: str | None,
|
|
127
|
+
) -> str | None:
|
|
128
|
+
if is_unresolvable_shell_value(body_file_path):
|
|
129
|
+
return None
|
|
130
|
+
resolved_path = Path(body_file_path)
|
|
131
|
+
if not resolved_path.is_absolute() and working_directory:
|
|
132
|
+
resolved_path = Path(working_directory) / body_file_path
|
|
133
|
+
try:
|
|
134
|
+
return resolved_path.read_text(encoding=BODY_FILE_ENCODING)
|
|
135
|
+
except (OSError, UnicodeError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _body_texts_from_tokens(
|
|
140
|
+
all_command_tokens: list[str],
|
|
141
|
+
working_directory: str | None,
|
|
142
|
+
) -> tuple[list[str], str | None]:
|
|
143
|
+
if not _tokens_name_gh_post_command(all_command_tokens):
|
|
144
|
+
return [], None
|
|
145
|
+
all_body_texts, all_body_file_paths = _collect_body_flag_values(all_command_tokens)
|
|
146
|
+
for each_path in all_body_file_paths:
|
|
147
|
+
file_text = _read_body_file(each_path, working_directory)
|
|
148
|
+
if file_text is None:
|
|
149
|
+
return [], (
|
|
150
|
+
"BLOCKED [pii_prevention_blocker]: durable post uses --body-file "
|
|
151
|
+
f"but '{each_path}' could not be read for PII scanning. Use an "
|
|
152
|
+
"absolute path, ensure the file exists, or pass --body text."
|
|
153
|
+
)
|
|
154
|
+
all_body_texts.append(file_text)
|
|
155
|
+
return all_body_texts, None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_gh_post_body_texts_for_privacy_gate(
|
|
159
|
+
command: str,
|
|
160
|
+
working_directory: str | None = None,
|
|
161
|
+
) -> tuple[list[str], str | None]:
|
|
162
|
+
"""Extract GitHub post text and reject unreadable declared body files.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
command: Raw GitHub CLI command.
|
|
166
|
+
working_directory: Base directory for relative body-file paths.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Body texts and no error, or no texts and a rejection message without body text.
|
|
170
|
+
"""
|
|
171
|
+
logical_line = get_logical_first_line(command)
|
|
172
|
+
if not logical_line:
|
|
173
|
+
return [], None
|
|
174
|
+
try:
|
|
175
|
+
all_command_tokens = shlex.split(logical_line, posix=False)
|
|
176
|
+
except ValueError:
|
|
177
|
+
return [], None
|
|
178
|
+
return _body_texts_from_tokens(all_command_tokens, working_directory)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def extract_mcp_body_texts(all_tool_input: dict[str, object]) -> list[str]:
|
|
182
|
+
"""Extract body and comment strings from a GitHub tool request.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
all_tool_input: GitHub tool input mapping.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Nonempty body and comment values.
|
|
189
|
+
"""
|
|
190
|
+
all_body_texts: list[str] = []
|
|
191
|
+
for each_name in ALL_MCP_BODY_PARAM_NAMES:
|
|
192
|
+
candidate_text = all_tool_input.get(each_name)
|
|
193
|
+
if isinstance(candidate_text, str) and candidate_text:
|
|
194
|
+
all_body_texts.append(candidate_text)
|
|
195
|
+
return all_body_texts
|
package/hooks/hooks.json
CHANGED
|
@@ -45,11 +45,6 @@
|
|
|
45
45
|
{
|
|
46
46
|
"matcher": "mcp__plugin_github_github__.*",
|
|
47
47
|
"hooks": [
|
|
48
|
-
{
|
|
49
|
-
"type": "command",
|
|
50
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/volatile_path_in_post_blocker.py",
|
|
51
|
-
"timeout": 10
|
|
52
|
-
},
|
|
53
48
|
{
|
|
54
49
|
"type": "command",
|
|
55
50
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pii_prevention_blocker.py",
|
|
@@ -149,11 +144,6 @@
|
|
|
149
144
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py",
|
|
150
145
|
"timeout": 10
|
|
151
146
|
},
|
|
152
|
-
{
|
|
153
|
-
"type": "command",
|
|
154
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/gh_pr_author_session_cleanup.py",
|
|
155
|
-
"timeout": 30
|
|
156
|
-
},
|
|
157
147
|
{
|
|
158
148
|
"type": "command",
|
|
159
149
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
|
|
@@ -251,11 +241,6 @@
|
|
|
251
241
|
"type": "command",
|
|
252
242
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
|
|
253
243
|
"timeout": 30
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
"type": "command",
|
|
257
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/pr_description_writer_spawn_tracker.py",
|
|
258
|
-
"timeout": 30
|
|
259
244
|
}
|
|
260
245
|
]
|
|
261
246
|
},
|
|
@@ -17,6 +17,5 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
17
17
|
__all__ = ["ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES"]
|
|
18
18
|
|
|
19
19
|
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
20
|
-
BashHostedHookEntry("blocking/gh_pr_author_restore.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
21
20
|
BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
22
21
|
)
|
|
@@ -56,7 +56,6 @@ class BashHostedHookEntry:
|
|
|
56
56
|
|
|
57
57
|
ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
58
58
|
BashHostedHookEntry("blocking/destructive_command_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
59
|
-
BashHostedHookEntry("blocking/gh_body_arg_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
60
59
|
BashHostedHookEntry("blocking/shell_substitution_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
61
60
|
BashHostedHookEntry("blocking/piped_pytest_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
62
61
|
BashHostedHookEntry(
|
|
@@ -65,15 +64,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
65
64
|
),
|
|
66
65
|
BashHostedHookEntry("blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
|
|
67
66
|
BashHostedHookEntry("blocking/nas_ssh_binary_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
68
|
-
BashHostedHookEntry("blocking/volatile_path_in_post_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
69
67
|
BashHostedHookEntry("blocking/pii_prevention_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
|
|
70
|
-
BashHostedHookEntry("blocking/conventional_pr_title_gate.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
71
68
|
BashHostedHookEntry("blocking/block_main_commit.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
72
69
|
BashHostedHookEntry("blocking/session_edit_stage_gate.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
73
|
-
BashHostedHookEntry("blocking/test_preflight_check.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
74
70
|
BashHostedHookEntry("blocking/windows_rmtree_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
75
|
-
BashHostedHookEntry("blocking/gh_pr_author_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
76
|
-
BashHostedHookEntry(
|
|
77
|
-
"blocking/pr_description_writer_gate.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
78
|
-
),
|
|
79
71
|
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Configuration constants for GitHub post body extraction."""
|
|
2
|
+
|
|
3
|
+
ALL_GH_POST_SUBCOMMANDS: dict[str, frozenset[str]] = {
|
|
4
|
+
"issue": frozenset({"comment", "create", "edit"}),
|
|
5
|
+
"pr": frozenset({"comment", "create", "edit", "review"}),
|
|
6
|
+
}
|
|
7
|
+
ALL_MCP_BODY_PARAM_NAMES: tuple[str, ...] = ("body", "comment")
|
|
8
|
+
BODY_FILE_ENCODING = "utf-8"
|
|
9
|
+
BODY_FLAG_WITH_VALUE_STEP = 2
|
|
10
|
+
GH_COMMAND_NAME = "gh"
|
|
11
|
+
MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT = 2
|
|
12
|
+
TOKEN_JOIN_SEPARATOR = " "
|
|
@@ -6,13 +6,6 @@ from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
|
6
6
|
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def test_roster_names_the_gh_pr_author_restore_hook() -> None:
|
|
10
|
-
all_script_paths = [
|
|
11
|
-
each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
12
|
-
]
|
|
13
|
-
assert "blocking/gh_pr_author_restore.py" in all_script_paths
|
|
14
|
-
|
|
15
|
-
|
|
16
9
|
def test_roster_names_the_test_failure_recorder_hook() -> None:
|
|
17
10
|
all_script_paths = [
|
|
18
11
|
each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
@@ -20,28 +20,21 @@ POWERSHELL_TOOL_NAME = ALL_CONSTANT_BINDINGS["POWERSHELL_TOOL_NAME"]
|
|
|
20
20
|
|
|
21
21
|
_EXPECTED_BASH_ORDER = (
|
|
22
22
|
"blocking/destructive_command_blocker.py",
|
|
23
|
-
"blocking/gh_body_arg_blocker.py",
|
|
24
23
|
"blocking/shell_substitution_blocker.py",
|
|
25
24
|
"blocking/piped_pytest_blocker.py",
|
|
26
25
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
27
26
|
"blocking/unscoped_search_blocker.py",
|
|
28
27
|
"blocking/nas_ssh_binary_enforcer.py",
|
|
29
|
-
"blocking/volatile_path_in_post_blocker.py",
|
|
30
28
|
"blocking/pii_prevention_blocker.py",
|
|
31
|
-
"blocking/conventional_pr_title_gate.py",
|
|
32
29
|
"blocking/block_main_commit.py",
|
|
33
30
|
"blocking/session_edit_stage_gate.py",
|
|
34
|
-
"blocking/test_preflight_check.py",
|
|
35
31
|
"blocking/windows_rmtree_blocker.py",
|
|
36
|
-
"blocking/gh_pr_author_enforcer.py",
|
|
37
|
-
"blocking/pr_description_writer_gate.py",
|
|
38
32
|
)
|
|
39
33
|
|
|
40
34
|
_POWERSHELL_APPLICABLE = (
|
|
41
35
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
42
36
|
"blocking/unscoped_search_blocker.py",
|
|
43
37
|
"blocking/pii_prevention_blocker.py",
|
|
44
|
-
"blocking/pr_description_writer_gate.py",
|
|
45
38
|
)
|
|
46
39
|
|
|
47
40
|
|
|
@@ -6,9 +6,7 @@ tokens that keep the recognizer to one unchained command, the pytest program
|
|
|
6
6
|
and Python interpreter basenames, and the flag-token marker the path
|
|
7
7
|
extractor uses to skip an option rather than an existing test file argument.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
module reuses that name rather than a second literal. The pytest-program and
|
|
11
|
-
interpreter recognition here is deliberately its own small, stdlib-only
|
|
9
|
+
The pytest-program and interpreter recognition here is deliberately its own small, stdlib-only
|
|
12
10
|
check rather than a reuse of ``piped_pytest_blocker_constants`` /
|
|
13
11
|
``pytest_invocation``: those solve the harder pipe-and-wrapper problem and
|
|
14
12
|
pull in a much larger transitive import for every Bash call regardless of
|
|
@@ -19,6 +17,8 @@ from __future__ import annotations
|
|
|
19
17
|
|
|
20
18
|
import re
|
|
21
19
|
|
|
20
|
+
BASH_TOOL_NAME = "Bash"
|
|
21
|
+
|
|
22
22
|
ALL_PYTEST_MENTION_SUBSTRINGS: tuple[str, ...] = ("pytest", "py.test")
|
|
23
23
|
EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
|
|
24
24
|
OPTION_TOKEN_PREFIX: str = "-"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Tests for GitHub post body extraction constants."""
|
|
2
|
+
|
|
3
|
+
from hooks_constants import gh_post_body_texts_constants as constants
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_post_actions_cover_pull_requests_and_issues() -> None:
|
|
7
|
+
assert constants.ALL_GH_POST_SUBCOMMANDS == {
|
|
8
|
+
"issue": frozenset({"comment", "create", "edit"}),
|
|
9
|
+
"pr": frozenset({"comment", "create", "edit", "review"}),
|
|
10
|
+
}
|
|
11
|
+
assert constants.ALL_MCP_BODY_PARAM_NAMES == ("body", "comment")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_parser_constants_are_stable() -> None:
|
|
15
|
+
assert constants.BODY_FILE_ENCODING == "utf-8"
|
|
16
|
+
assert constants.BODY_FLAG_WITH_VALUE_STEP == 2
|
|
17
|
+
assert constants.GH_COMMAND_NAME == "gh"
|
|
18
|
+
assert constants.MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT == 2
|
|
19
|
+
assert constants.TOKEN_JOIN_SEPARATOR == " "
|
|
@@ -101,13 +101,13 @@ try:
|
|
|
101
101
|
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
102
102
|
if each_bootstrap_directory not in sys.path:
|
|
103
103
|
sys.path.insert(0, each_bootstrap_directory)
|
|
104
|
-
from hooks_constants.gh_pr_author_swap_constants import BASH_TOOL_NAME
|
|
105
104
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
106
105
|
from hooks_constants.test_failure_recorder_constants import (
|
|
107
106
|
ALL_CHAINING_OPERATOR_SUBSTRINGS,
|
|
108
107
|
ALL_PYTEST_MENTION_SUBSTRINGS,
|
|
109
108
|
ALL_PYTEST_PROGRAM_BASENAMES,
|
|
110
109
|
ALL_REDIRECTION_TOKENS,
|
|
110
|
+
BASH_TOOL_NAME,
|
|
111
111
|
EXIT_CODE_ERROR_PREFIX,
|
|
112
112
|
MODULE_RUN_FLAG,
|
|
113
113
|
NODE_ID_FILE_PATH_SEPARATOR,
|
package/hooks/pyproject.toml
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for durable GitHub post body extraction."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from gh_post_body_texts import (
|
|
8
|
+
extract_gh_post_body_texts_for_privacy_gate,
|
|
9
|
+
extract_mcp_body_texts,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize(
|
|
14
|
+
("command", "expected_text"),
|
|
15
|
+
[
|
|
16
|
+
('gh pr create --body "pull request text"', "pull request text"),
|
|
17
|
+
('gh pr comment 12 --body="comment text"', "comment text"),
|
|
18
|
+
('gh issue edit 7 -b "issue text"', "issue text"),
|
|
19
|
+
('GH_HOST=github.example gh pr review 8 --body "review text"', "review text"),
|
|
20
|
+
],
|
|
21
|
+
)
|
|
22
|
+
def test_inline_post_bodies_are_extracted(command: str, expected_text: str) -> None:
|
|
23
|
+
all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(command)
|
|
24
|
+
assert all_body_texts == [expected_text]
|
|
25
|
+
assert deny_reason is None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_relative_body_file_uses_supplied_working_directory(tmp_path: Path) -> None:
|
|
29
|
+
body_file = tmp_path / "body.md"
|
|
30
|
+
body_file.write_text("body file text", encoding="utf-8")
|
|
31
|
+
all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(
|
|
32
|
+
"gh pr comment 12 --body-file body.md",
|
|
33
|
+
str(tmp_path),
|
|
34
|
+
)
|
|
35
|
+
assert all_body_texts == ["body file text"]
|
|
36
|
+
assert deny_reason is None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("body_file_text", ["missing.md", "$BODY_FILE", "-"])
|
|
40
|
+
def test_unreadable_body_file_fails_closed(body_file_text: str) -> None:
|
|
41
|
+
all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(
|
|
42
|
+
f"gh pr comment 12 --body-file {body_file_text}"
|
|
43
|
+
)
|
|
44
|
+
assert all_body_texts == []
|
|
45
|
+
assert deny_reason is not None
|
|
46
|
+
assert "could not be read" in deny_reason
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_invalid_utf8_body_file_fails_closed(tmp_path: Path) -> None:
|
|
50
|
+
body_file = tmp_path / "body.md"
|
|
51
|
+
body_file.write_bytes(b"\xff")
|
|
52
|
+
all_body_texts, deny_reason = extract_gh_post_body_texts_for_privacy_gate(
|
|
53
|
+
f'gh issue create --body-file "{body_file}"'
|
|
54
|
+
)
|
|
55
|
+
assert all_body_texts == []
|
|
56
|
+
assert deny_reason is not None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
"command",
|
|
61
|
+
[
|
|
62
|
+
"gh pr view 12",
|
|
63
|
+
'echo "gh pr comment 12 --body text"',
|
|
64
|
+
"git status",
|
|
65
|
+
"",
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
def test_non_post_commands_have_no_body(command: str) -> None:
|
|
69
|
+
assert extract_gh_post_body_texts_for_privacy_gate(command) == ([], None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_mcp_body_and_comment_values_are_extracted() -> None:
|
|
73
|
+
assert extract_mcp_body_texts(
|
|
74
|
+
{"body": "body text", "comment": "comment text", "title": "ignored"}
|
|
75
|
+
) == ["body text", "comment text"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_mcp_non_string_and_empty_values_are_ignored() -> None:
|
|
79
|
+
assert extract_mcp_body_texts({"body": "", "comment": 7}) == []
|
package/package.json
CHANGED
|
@@ -1,38 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# GitHub post input rules
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## When this applies
|
|
4
|
+
|
|
5
|
+
Use this rule for a GitHub issue, pull request, comment, or review created
|
|
6
|
+
through `gh` or a GitHub MCP post tool.
|
|
4
7
|
|
|
5
8
|
## Rule
|
|
6
9
|
|
|
7
|
-
A post
|
|
10
|
+
A post remains after the job ends. Job scratch directories, worktrees, and
|
|
11
|
+
system temp folders do not. Do not put a path from one of those directories in
|
|
12
|
+
a post.
|
|
8
13
|
|
|
9
|
-
Handle
|
|
14
|
+
Handle text and binary content differently:
|
|
10
15
|
|
|
11
|
-
- **Text data**
|
|
12
|
-
|
|
16
|
+
- **Text data** such as logs, tables, diffs, and stack traces belongs inline in
|
|
17
|
+
the post body. Do not link a scratch file that holds text data.
|
|
18
|
+
- **Binary artifacts** such as images, screenshots, and archives belong in the
|
|
19
|
+
repository's durable `artifacts` release. Use the helper:
|
|
13
20
|
|
|
14
21
|
```
|
|
15
22
|
python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>
|
|
16
23
|
```
|
|
17
24
|
|
|
18
|
-
|
|
25
|
+
The helper creates the `artifacts` prerelease when needed, uploads the file
|
|
26
|
+
under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints a permanent download
|
|
27
|
+
URL. Put that URL in the post.
|
|
19
28
|
|
|
20
29
|
## Volatile paths that must not appear in a post body
|
|
21
30
|
|
|
22
|
-
- A job scratch directory
|
|
23
|
-
- A worktree
|
|
24
|
-
- A system temp location
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Both slash directions count.
|
|
28
|
-
|
|
29
|
-
The worktree and job-scratch entries count as a path when either:
|
|
31
|
+
- A job scratch directory: `.claude-profile-a/jobs/`
|
|
32
|
+
- A worktree: `.claude/worktrees/`
|
|
33
|
+
- A system temp location: `AppData\\Local\\Temp`, `%TEMP%`, `$env:TEMP`, or
|
|
34
|
+
`/tmp/`
|
|
35
|
+
- The job scratch environment variable: `$CLAUDE_JOB_DIR`
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
Both slash directions count. The path rule applies when a slash or backslash
|
|
38
|
+
precedes a marker, or when a path segment follows it. A standalone directory
|
|
39
|
+
name does not form a path.
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
## Validation
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
Run `.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py` before the
|
|
44
|
+
server write. Pass the matching action and body file. Use `pr-create`,
|
|
45
|
+
`pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
|
|
46
|
+
`issue-comment`, or `github-mcp-post`.
|
|
37
47
|
|
|
38
|
-
The
|
|
48
|
+
The linter reads the body file and reports a volatile local path without
|
|
49
|
+
printing the body. Fix the body and rerun the linter before posting.
|
|
@@ -1,26 +1,36 @@
|
|
|
1
|
-
# gh CLI
|
|
1
|
+
# gh CLI conventions
|
|
2
2
|
|
|
3
|
-
Two
|
|
3
|
+
Two `gh` call shapes need explicit handling.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Put body content in a file
|
|
6
6
|
|
|
7
|
-
Every `gh` command
|
|
7
|
+
Every `gh` command that carries markdown body content uses
|
|
8
|
+
`--body-file <path>`. This applies to `gh pr create`, `gh pr edit`,
|
|
9
|
+
`gh pr comment`, `gh pr review`, `gh issue create`, `gh issue edit`, and
|
|
10
|
+
`gh issue comment`. Never pass a `--body` or `-b` string. Write the file as
|
|
11
|
+
BOM-free UTF-8:
|
|
8
12
|
|
|
9
13
|
```powershell
|
|
10
14
|
[IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
MCP GitHub tools take `body` as a structured parameter
|
|
17
|
+
MCP GitHub tools take `body` as a structured parameter. Write the same body to
|
|
18
|
+
a UTF-8 file and run the shared linter before sending that parameter.
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
For pull requests, use
|
|
21
|
+
`.agents/skills/pull-request/scripts/pull_request.py`. It passes
|
|
22
|
+
`--body-file` to `gh` after the action-aware linter succeeds. For issues and
|
|
23
|
+
GitHub MCP posts, run the linter directly with `issue-create`, `issue-edit`,
|
|
24
|
+
`issue-comment`, or `github-mcp-post` as the action.
|
|
16
25
|
|
|
17
26
|
## Paginated reads slurp before they filter
|
|
18
27
|
|
|
19
|
-
Every `gh api` read of a paginated GitHub list endpoint
|
|
28
|
+
Every `gh api` read of a paginated GitHub list endpoint uses
|
|
29
|
+
`--paginate --slurp` and pipes the result to external `jq`. This applies to PR
|
|
30
|
+
reviews, comments, and files, plus issue comments, pulls, and issues. The
|
|
31
|
+
built-in `--jq` runs once per page and can produce a wrong cross-page result.
|
|
20
32
|
|
|
21
|
-
Single-object endpoints
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- [`destructive-commands.md`](destructive-commands.md) — why a body describing `rm -rf` must travel by file path.
|
|
26
|
-
- [`durable-post-artifacts.md`](durable-post-artifacts.md) — what a post body may reference.
|
|
33
|
+
Single-object endpoints such as `pulls/<n>` and `issues/<n>` do not need
|
|
34
|
+
pagination and may use `--jq` directly. For a newest-first walk, sort the
|
|
35
|
+
slurped array and take the last element. For one page, cap the request with a
|
|
36
|
+
`per_page` query parameter.
|
package/rules/git-workflow.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Git
|
|
1
|
+
# Git workflow
|
|
2
2
|
|
|
3
|
-
User-level rule: applies to **every** git repo that uses GitHub with `gh
|
|
3
|
+
User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small or non-primary repos follow the same rule unless the user says otherwise in the session.
|
|
4
4
|
|
|
5
|
-
## Workflow
|
|
5
|
+
## Workflow decision tree
|
|
6
6
|
|
|
7
7
|
**When to use stacked PRs:** Feature B depends on Feature A's implementation
|
|
8
8
|
|
|
@@ -13,19 +13,32 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh` (no ex
|
|
|
13
13
|
2. Get reviewed and MERGE infrastructure first
|
|
14
14
|
3. Launch parallel feature PRs that use merged infrastructure
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Pull request submission rules
|
|
17
17
|
|
|
18
18
|
**ALWAYS create PRs as DRAFT:** Use `gh pr create --draft` for ALL PRs
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Use the `pr-description-writer` agent before creating a pull request or
|
|
21
|
+
rewriting its full description. Publish its title and body file through
|
|
22
|
+
`.agents/skills/pull-request/scripts/pull_request.py`. The
|
|
23
|
+
`pr-title-description` skill may review the writer's output but cannot replace
|
|
24
|
+
the required writer.
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
Run `_shared/pr-loop/scripts/durable_post_lint.py` before any pull request,
|
|
27
|
+
issue, or GitHub MCP post. The linter checks the action-specific title, body,
|
|
28
|
+
and volatile-path rules before credential lookup or network access.
|
|
29
|
+
|
|
30
|
+
Use `.agents/skills/pull-request/scripts/recover_legacy_author.py
|
|
31
|
+
<exact-state-file> --confirm-inactive` only for one explicitly selected legacy
|
|
32
|
+
author record. Do not infer a record from age alone. Keep every other record
|
|
33
|
+
untouched.
|
|
34
|
+
|
|
35
|
+
## Git golden rules
|
|
23
36
|
|
|
24
37
|
1. **DRAFT BEFORE PUSH**: When pushing ANYTHING to a PR, it MUST be in draft state first
|
|
25
38
|
- Before push: `gh pr ready --undo`
|
|
26
39
|
- After review approved: `gh pr ready`
|
|
27
40
|
|
|
28
|
-
## Never
|
|
41
|
+
## Never commit working documents or images
|
|
29
42
|
|
|
30
43
|
**NEVER commit these files to the repo:**
|
|
31
44
|
|
|
@@ -49,4 +62,4 @@ An image a PR needs as visual evidence is not an exception to that row. Upload i
|
|
|
49
62
|
|
|
50
63
|
Repair only reported findings, then re-verify after every repair.
|
|
51
64
|
|
|
52
|
-
Every `gh` post in this workflow uses `--body-file` per `gh-cli-conventions.md` and keeps volatile scratch paths out per `durable-post-artifacts.md
|
|
65
|
+
Every `gh` post in this workflow uses `--body-file` per `gh-cli-conventions.md` and keeps volatile scratch paths out per `durable-post-artifacts.md`. Stage session edits per `re-stage-before-commit.md` before each commit.
|