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.
Files changed (78) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  6. package/.agents/skills/pull-request/SKILL.md +218 -0
  7. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  8. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  9. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  10. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  11. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  12. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  13. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  14. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  15. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  16. package/.agents/skills/test-runner/SKILL.md +69 -0
  17. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  18. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  19. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  20. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  21. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  22. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  23. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  24. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  25. package/bin/ever-shipped-skills.mjs +2 -0
  26. package/bin/ever-shipped-skills.test.mjs +8 -0
  27. package/bin/install.mjs +9 -1
  28. package/bin/install.prune.test.mjs +90 -0
  29. package/bin/install.test.mjs +24 -7
  30. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  31. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  33. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  34. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  35. package/hooks/gh_post_body_texts.py +195 -0
  36. package/hooks/hooks.json +0 -15
  37. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  38. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -8
  39. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  40. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  41. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -7
  42. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  43. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  44. package/hooks/observability/test_failure_recorder.py +1 -1
  45. package/hooks/pyproject.toml +1 -1
  46. package/hooks/test_gh_post_body_texts.py +79 -0
  47. package/package.json +1 -1
  48. package/rules/durable-post-artifacts.md +31 -20
  49. package/rules/gh-cli-conventions.md +23 -13
  50. package/rules/git-workflow.md +21 -8
  51. package/scripts/policy_lint/adapter_configuration.py +53 -12
  52. package/scripts/repository_checks/config/constants.py +0 -5
  53. package/scripts/tests/test_adapter_configuration.py +54 -0
  54. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  55. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  56. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  57. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  58. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  59. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  60. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  61. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  62. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  63. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  64. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  65. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  66. package/hooks/blocking/test_preflight_check.py +0 -343
  67. package/hooks/blocking/test_test_preflight_check.py +0 -80
  68. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  69. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  70. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  71. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  72. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  73. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  74. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  75. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  76. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  77. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  78. 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
- ``BASH_TOOL_NAME`` lives in ``gh_pr_author_swap_constants`` already; this
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,
@@ -24,7 +24,7 @@ exclude = [
24
24
 
25
25
 
26
26
  [[tool.mypy.overrides]]
27
- module = ["exempt_paths", "code_rules_path_utils", "gh_pr_author_enforcer"]
27
+ module = ["exempt_paths", "code_rules_path_utils"]
28
28
  warn_unused_ignores = false
29
29
 
30
30
  [[tool.mypy.overrides]]
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,38 +1,49 @@
1
- # Durable Post Artifacts
1
+ # GitHub post input rules
2
2
 
3
- **When this applies:** Any GitHub post that lives on the server — an issue, a pull request, a comment, or a review — created through `gh` (`gh pr create/comment/edit/review`, `gh issue create/comment/edit`) or a GitHub MCP post tool.
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 lives forever. Job scratch directories, worktrees, and system temp folders do not — they are cleaned soon after the run that made them. So a post must never point at a path in that scratch. The moment the directory is cleaned, the reference breaks and the reader is left with a dead path.
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 the two kinds of content differently:
14
+ Handle text and binary content differently:
10
15
 
11
- - **Text data** (logs, tables, diffs, stack traces): paste the actual text inline in the post body. Do not link a scratch file that holds it.
12
- - **Binary artifacts** (images, screenshots, archives): upload the file to the repository's durable `artifacts` release and link the permanent URL. Use the helper:
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
- It ensures the repository has a prerelease tagged `artifacts`, uploads the file under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints the permanent download URL. Put that URL in the post.
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 (`.claude-profile-a/jobs/`)
23
- - A worktree (`.claude/worktrees/`)
24
- - A system temp location (`AppData\Local\Temp`, `%TEMP%`, `$env:TEMP`, `/tmp/`)
25
- - The job scratch environment variable (`$CLAUDE_JOB_DIR`)
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
- - A `/` or `\` sits right before them — a drive-letter path (`C:\Users\me\.claude\worktrees\wt\f.py`), a home path (`~/.claude/worktrees/wt`), or a POSIX absolute path (`/home/me/.claude-profile-a/jobs/j/log.txt`).
32
- - A path segment follows them — a relative path that names a child under that directory (`see .claude/worktrees/wt-1/notes.md`, a markdown link target, or `cd .claude/worktrees/wt-199`).
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
- With neither anchor the text names the directory rather than something inside it, and it posts — a quoted config constant, a backticked directory name, or a placeholder form such as `.claude/worktrees/<name>`. Un-backticked prose that puts a word immediately after the marker reads the same as a relative path and is blocked; the placeholder form is the documented escape.
41
+ ## Validation
35
42
 
36
- ## Enforcement
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 `volatile_path_in_post_blocker` PreToolUse hook reads the body of each `gh` post command and each GitHub MCP post call, scans it for these markers, and blocks the post when it finds one. For a `--body-file`, the hook reads the file and scans its contents, so writing the body to a temp file and passing it with `--body-file` stays allowed — what the hook rejects is a volatile path inside the text that gets posted.
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 Conventions
1
+ # gh CLI conventions
2
2
 
3
- Two call shapes the `gh` CLI gets wrong by default.
3
+ Two `gh` call shapes need explicit handling.
4
4
 
5
- ## Body content goes in a file
5
+ ## Put body content in a file
6
6
 
7
- Every `gh` command carrying markdown body content (`gh pr create/edit/comment/review`, `gh issue create/edit/comment`) uses `--body-file <path>` with a temp file — never a `--body` / `-b` string, where backticks land on GitHub as a literal `` \` ``. Write the temp file BOM-free:
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 and are unaffected.
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
- `gh_body_arg_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) blocks `--body <arg>` and returns the corrective message.
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 (PR `reviews` / `comments` / `files`, issue `comments`, `pulls`, `issues`) uses `--paginate --slurp` piped to **external** `jq`. The built-in `--jq` runs per page, so a cross-page operation like `sort_by | last` gives a wrong-but-confident result.
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 (`pulls/<n>`, `issues/<n>`) skip pagination and may use `--jq` directly. For a newest-first walk, sort the slurped array and take the last element; for single-page bounds, cap with a `per_page` query parameter.
22
-
23
- ## Sibling rules
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.
@@ -1,8 +1,8 @@
1
- # Git Workflow
1
+ # Git workflow
2
2
 
3
- User-level rule: applies to **every** git repo that uses GitHub with `gh` (no exceptions for “small” or non-primary repos unless the user says otherwise in the session).
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 Decision Tree
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
- ## PR Submission Rules
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
- **The pr-description-writer agent writes the body:** Spawn the `pr-description-writer` agent (Agent tool, `subagent_type` `pr-description-writer`), then publish what it returns with `--body-file`. `pr_description_writer_gate.py` (PreToolUse on Bash and PowerShell, hosted by `bash_pre_tool_use_dispatcher`) denies a `gh pr create` when the session records no such spawn. A trailing `# pr-description-skip` comment on the command opts out for a body you write yourself.
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
- ## Git Golden Rules (NON-NEGOTIABLE)
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 Commit Working Documents or Images
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`; stage session edits per `re-stage-before-commit.md` before each commit.
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.