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
@@ -1,443 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block a non-conventional gh pr create/edit --title in a repo whose CI enforces it.
3
-
4
- Autoconverge's standards-deferral path opens draft environment-hardening PRs
5
- across many repos. A repo whose CI runs a semantic-pull-request check (the
6
- amannn/action-semantic-pull-request GitHub Action or an equivalent) rejects a
7
- PR whose title is not a Conventional Commit, and that failure only surfaces
8
- after the PR already exists. This hook blocks the malformed `gh pr create` or
9
- `gh pr edit` before the PR is opened, so the title gets fixed first.
10
-
11
- Detection strategy: match `gh pr create`/`gh pr edit` on the command's logical
12
- first line (after joining bash/PowerShell continuations), extract the
13
- --title/-t value with a small shlex-based token scan, then resolve the git
14
- repo root from the tool call's cwd and scan `.github/workflows/*.yml(.yaml)`
15
- for a semantic-pull-request marker string. The gate only fires when a marker
16
- workflow leaves the action's `types:` input at its default Conventional
17
- Commits list; every other case -- an unparseable command, a missing title, a
18
- title that is an unresolvable shell variable (a `$`-prefixed value this hook
19
- cannot resolve), a --repo flag pointing at a repo this hook cannot inspect on
20
- disk, a directory that resolves to no repo root, a repo with no matching
21
- workflow, or a marker workflow whose semantic-pull-request action step declares
22
- a custom `types:` input (so this hook cannot know the repo's allowed types) --
23
- fails OPEN (allow), since the authoritative CI check still runs on GitHub.
24
- """
25
-
26
- import json
27
- import shlex
28
- import sys
29
- from pathlib import Path
30
-
31
- _blocking_dir = str(Path(__file__).resolve().parent)
32
- if _blocking_dir not in sys.path:
33
- sys.path.insert(0, _blocking_dir)
34
-
35
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
36
- if _hooks_dir not in sys.path:
37
- sys.path.insert(0, _hooks_dir)
38
-
39
- from blocking._gh_body_arg_utils import ( # noqa: E402
40
- all_value_flags,
41
- count_extra_tokens_to_skip_for_split_quoted_value,
42
- get_logical_first_line,
43
- is_flag_shaped_token,
44
- is_unresolvable_shell_value,
45
- iter_significant_tokens,
46
- match_non_body_value_flag_equals_prefix,
47
- strip_surrounding_quotes,
48
- )
49
- from hooks_constants.conventional_pr_title_gate_constants import ( # noqa: E402
50
- ALL_GH_EXECUTABLE_BASENAMES,
51
- ALL_PR_TITLE_SUBCOMMAND_VERBS,
52
- ALL_SEMANTIC_TITLE_CI_MARKERS,
53
- ALL_WORKFLOW_FILE_GLOB_PATTERNS,
54
- BASH_TOOL_NAME,
55
- CONVENTIONAL_COMMIT_TITLE_PATTERN,
56
- CORRECTIVE_MESSAGE,
57
- GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT,
58
- PR_SUBCOMMAND_TOKEN,
59
- REPO_LONG_FLAG,
60
- REPO_SHORT_FLAG,
61
- SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN,
62
- SEMANTIC_ACTION_TYPES_INPUT_PATTERN,
63
- TITLE_LONG_FLAG,
64
- TITLE_SHORT_FLAG,
65
- WORKFLOWS_DIRECTORY_RELATIVE_PATH,
66
- YAML_LIST_ITEM_PREFIX,
67
- )
68
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
69
- from hooks_constants.project_paths_reader import find_git_root as resolve_repo_root # noqa: E402
70
-
71
-
72
- def _matches_gh_pr_title_subcommand(command: str) -> bool:
73
- all_tokens = _parsed_command_tokens(command)
74
- if all_tokens is None or len(all_tokens) < GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT:
75
- return False
76
- executable_basename = Path(strip_surrounding_quotes(all_tokens[0])).name
77
- if executable_basename not in ALL_GH_EXECUTABLE_BASENAMES:
78
- return False
79
- if strip_surrounding_quotes(all_tokens[1]) != PR_SUBCOMMAND_TOKEN:
80
- return False
81
- return strip_surrounding_quotes(all_tokens[2]) in ALL_PR_TITLE_SUBCOMMAND_VERBS
82
-
83
-
84
- def _parsed_command_tokens(command: str) -> list[str] | None:
85
- logical_line = get_logical_first_line(command)
86
- if not logical_line:
87
- return None
88
- try:
89
- return shlex.split(logical_line, posix=False)
90
- except ValueError:
91
- return None
92
-
93
-
94
- def _joined_equals_form_value(
95
- equals_prefix: str, each_token: str, all_remaining_tokens: list[str]
96
- ) -> str | None:
97
- """Join an equals-form flag value that shlex(posix=False) split on an internal space.
98
-
99
- shlex.split(command, posix=False) treats the quote character inside
100
- `--title="fix: a b"` as starting mid-token, so it splits the quoted value
101
- at each unquoted-looking space instead of keeping it as one token. This
102
- rejoins those split tokens up to the matching closing quote.
103
- """
104
- value_token = each_token[len(equals_prefix) :]
105
- extra_tokens_to_join = count_extra_tokens_to_skip_for_split_quoted_value(
106
- all_remaining_tokens, value_token
107
- )
108
- if extra_tokens_to_join is None:
109
- return None
110
- return strip_surrounding_quotes(
111
- " ".join([value_token, *all_remaining_tokens[:extra_tokens_to_join]])
112
- )
113
-
114
-
115
- def _extract_flag_value(
116
- command: str,
117
- long_flag: str,
118
- short_flag: str,
119
- all_pre_tokenized: tuple[str, list[str]] | None = None,
120
- ) -> str | None:
121
- """Return the value of long_flag/short_flag, skipping preceding value-flag values.
122
-
123
- Uses iter_significant_tokens so a long_flag/short_flag word exposed inside an
124
- earlier value flag's split quoted value is never mistaken for the flag
125
- itself, then scans the raw tokens to read the flag's own value. Recognizes
126
- the space form, the equals form, and the attached short form
127
- (``-Rowner/repo``). Returns None when the flag is absent or the command is
128
- unparseable.
129
-
130
- Args:
131
- command: The full shell command string.
132
- long_flag: The flag's long spelling, such as ``--title``.
133
- short_flag: The flag's short spelling, such as ``-t``.
134
- all_pre_tokenized: An optional ``(logical_line, raw_tokens)`` pair reused
135
- in place of recomputing the logical line and shlex split.
136
- """
137
- if all_pre_tokenized is not None:
138
- logical_line, all_tokens = all_pre_tokenized
139
- else:
140
- logical_line = get_logical_first_line(command)
141
- if not logical_line:
142
- return None
143
- try:
144
- all_tokens = shlex.split(logical_line, posix=False)
145
- except ValueError:
146
- return None
147
- try:
148
- all_significant_tokens = list(
149
- iter_significant_tokens(command, pre_tokenized=(logical_line, all_tokens))
150
- )
151
- except ValueError:
152
- return None
153
- if not _flag_present_in_significant_tokens(all_significant_tokens, long_flag, short_flag):
154
- return None
155
- return _scan_tokens_for_flag_value(all_tokens, long_flag, short_flag)
156
-
157
-
158
- def _flag_present_in_significant_tokens(
159
- all_significant_tokens: list[tuple[str, list[str]]], long_flag: str, short_flag: str
160
- ) -> bool:
161
- return any(
162
- _token_begins_target_flag(each_token, long_flag, short_flag)
163
- for each_token, _all_remaining_tokens in all_significant_tokens
164
- )
165
-
166
-
167
- def _scan_tokens_for_flag_value(
168
- all_tokens: list[str], long_flag: str, short_flag: str
169
- ) -> str | None:
170
- token_index = 0
171
- while token_index < len(all_tokens):
172
- each_token = all_tokens[token_index]
173
- all_remaining_tokens = all_tokens[token_index + 1 :]
174
- if _token_begins_target_flag(each_token, long_flag, short_flag):
175
- return _target_flag_value(each_token, all_remaining_tokens, long_flag, short_flag)
176
- token_index = _index_after_value_flag(all_tokens, token_index)
177
- return None
178
-
179
-
180
- def _token_begins_target_flag(each_token: str, long_flag: str, short_flag: str) -> bool:
181
- if each_token in {long_flag, short_flag}:
182
- return True
183
- if each_token.startswith(f"{long_flag}=") or each_token.startswith(f"{short_flag}="):
184
- return True
185
- return _is_attached_short_flag(each_token, short_flag)
186
-
187
-
188
- def _is_attached_short_flag(each_token: str, short_flag: str) -> bool:
189
- if each_token == short_flag or not each_token.startswith(short_flag):
190
- return False
191
- return not each_token.startswith(f"{short_flag}=")
192
-
193
-
194
- def _target_flag_value(
195
- each_token: str, all_remaining_tokens: list[str], long_flag: str, short_flag: str
196
- ) -> str | None:
197
- long_flag_equals_prefix = f"{long_flag}="
198
- short_flag_equals_prefix = f"{short_flag}="
199
- if each_token.startswith(long_flag_equals_prefix):
200
- return _joined_equals_form_value(long_flag_equals_prefix, each_token, all_remaining_tokens)
201
- if each_token.startswith(short_flag_equals_prefix):
202
- return _joined_equals_form_value(short_flag_equals_prefix, each_token, all_remaining_tokens)
203
- if each_token in {long_flag, short_flag}:
204
- if not all_remaining_tokens:
205
- return None
206
- return strip_surrounding_quotes(all_remaining_tokens[0])
207
- return strip_surrounding_quotes(each_token[len(short_flag) :])
208
-
209
-
210
- def _index_after_value_flag(all_tokens: list[str], token_index: int) -> int:
211
- each_token = all_tokens[token_index]
212
- all_remaining_tokens = all_tokens[token_index + 1 :]
213
- equals_prefix = match_non_body_value_flag_equals_prefix(each_token)
214
- if equals_prefix is not None:
215
- value_token = each_token[len(equals_prefix) :]
216
- extra_tokens_to_skip = count_extra_tokens_to_skip_for_split_quoted_value(
217
- all_remaining_tokens, value_token
218
- )
219
- return token_index + 1 + (extra_tokens_to_skip or 0)
220
- if each_token not in all_value_flags:
221
- return token_index + 1
222
- if not all_remaining_tokens or is_flag_shaped_token(all_remaining_tokens[0]):
223
- return token_index + 1
224
- value_token = all_remaining_tokens[0]
225
- extra_tokens_to_skip = count_extra_tokens_to_skip_for_split_quoted_value(
226
- all_remaining_tokens[1:], value_token
227
- )
228
- return token_index + 1 + 1 + (extra_tokens_to_skip or 0)
229
-
230
-
231
- def _is_conventional_commit_title(title: str) -> bool:
232
- return bool(CONVENTIONAL_COMMIT_TITLE_PATTERN.match(title))
233
-
234
-
235
- def _repo_enforces_default_conventional_pr_titles(repo_root: str) -> bool:
236
- """Return whether a marker workflow enforces PR titles against the default type list.
237
-
238
- A semantic-pull-request action step that declares a custom ``types:`` input
239
- accepts types this gate's default Conventional Commits list omits, so the gate
240
- cannot judge a title against that workflow's allowed types. A repo can run
241
- several marker workflows at once: when any one of them leaves the action's type
242
- list at its default, that workflow's CI check rejects a non-Conventional title,
243
- so the gate blocks. The gate returns True when at least one marker workflow uses
244
- the default type list, and False (fail open) only when every marker workflow
245
- customizes its types -- the authoritative CI check on GitHub still validates the
246
- title in that case.
247
- """
248
- workflows_directory = Path(repo_root) / WORKFLOWS_DIRECTORY_RELATIVE_PATH
249
- if not workflows_directory.is_dir():
250
- return False
251
- all_marker_workflow_texts = _all_semantic_marker_workflow_texts(workflows_directory)
252
- if not all_marker_workflow_texts:
253
- return False
254
- return any(
255
- not _workflow_customizes_semantic_types(each_workflow_text)
256
- for each_workflow_text in all_marker_workflow_texts
257
- )
258
-
259
-
260
- def _all_semantic_marker_workflow_texts(workflows_directory: Path) -> list[str]:
261
- all_marker_workflow_texts: list[str] = []
262
- for each_glob_pattern in ALL_WORKFLOW_FILE_GLOB_PATTERNS:
263
- for each_workflow_file in workflows_directory.glob(each_glob_pattern):
264
- workflow_text = _read_workflow_text(each_workflow_file)
265
- if _text_has_semantic_marker(workflow_text):
266
- all_marker_workflow_texts.append(workflow_text)
267
- return all_marker_workflow_texts
268
-
269
-
270
- def _text_has_semantic_marker(workflow_text: str) -> bool:
271
- return any(each_marker in workflow_text for each_marker in ALL_SEMANTIC_TITLE_CI_MARKERS)
272
-
273
-
274
- def _workflow_customizes_semantic_types(workflow_text: str) -> bool:
275
- """Return whether a semantic-pull-request step declares a custom ``types:`` input.
276
-
277
- The action reads a ``types:`` input as the complete allowed-type list,
278
- replacing this gate's default Conventional Commits set, so a title this gate
279
- would reject may be one the repo's own CI accepts. Scoping the search to the
280
- marker step's indented block keeps the top-level ``on: pull_request: types:``
281
- event-activity list -- an unrelated ``types:`` key -- out of the match.
282
- """
283
- all_lines = workflow_text.splitlines()
284
- for each_line_index, each_line in enumerate(all_lines):
285
- if not _text_has_semantic_marker(each_line):
286
- continue
287
- if _step_block_declares_types_input(all_lines, each_line_index):
288
- return True
289
- return False
290
-
291
-
292
- def _step_block_declares_types_input(all_lines: list[str], marker_line_index: int) -> bool:
293
- step_item_index = _enclosing_step_item_index(all_lines, marker_line_index)
294
- step_indentation = _leading_space_count(all_lines[step_item_index])
295
- for each_line in all_lines[step_item_index + 1 :]:
296
- if not each_line.strip():
297
- continue
298
- if _leading_space_count(each_line) <= step_indentation:
299
- return False
300
- if _line_declares_types_input(each_line):
301
- return True
302
- return False
303
-
304
-
305
- def _line_declares_types_input(line: str) -> bool:
306
- """Return whether a step-block line declares a semantic-pull-request ``types:`` input.
307
-
308
- Matches both the block-style key (``types: |`` on its own line) and the
309
- flow-style mapping (``with: { types: [feat, wip] }``), so a custom type list
310
- written in either YAML shape counts.
311
- """
312
- if SEMANTIC_ACTION_TYPES_INPUT_PATTERN.match(line):
313
- return True
314
- return bool(SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN.search(line))
315
-
316
-
317
- def _enclosing_step_item_index(all_lines: list[str], marker_line_index: int) -> int:
318
- """Return the index of the ``- `` list item that encloses the marker line.
319
-
320
- The semantic-pull-request marker sits either on the step's ``- `` list-item
321
- line or on a sibling ``uses:`` key one level in. Scanning the step block from
322
- that list item -- rather than the marker line -- reaches a nested
323
- ``with: types:`` input whether it sits above or below the ``uses:`` key,
324
- since YAML mapping key order within a step is arbitrary.
325
- """
326
- marker_indentation = _leading_space_count(all_lines[marker_line_index])
327
- for each_candidate_index in range(marker_line_index, -1, -1):
328
- candidate_line = all_lines[each_candidate_index]
329
- if not _is_yaml_list_item(candidate_line):
330
- continue
331
- if _leading_space_count(candidate_line) <= marker_indentation:
332
- return each_candidate_index
333
- return marker_line_index
334
-
335
-
336
- def _is_yaml_list_item(line: str) -> bool:
337
- return line.lstrip().startswith(YAML_LIST_ITEM_PREFIX)
338
-
339
-
340
- def _leading_space_count(line: str) -> int:
341
- return len(line) - len(line.lstrip())
342
-
343
-
344
- def _read_workflow_text(workflow_file: Path) -> str:
345
- try:
346
- return workflow_file.read_text(encoding="utf-8", errors="replace")
347
- except OSError:
348
- return ""
349
-
350
-
351
- def _pull_request_title_to_validate(command: str) -> str | None:
352
- """Return the --title/-t value this call should be checked against, or None.
353
-
354
- Returns None for a command that is not `gh pr create`/`gh pr edit`, an
355
- unparseable command, a command carrying a --repo/-R flag (a repo this hook
356
- cannot inspect on the local filesystem), a command with no --title/-t
357
- value, or a title that is an unresolvable shell variable (a `$`-prefixed
358
- value whose resolved text this hook cannot know), which fails open so the
359
- authoritative CI check on GitHub decides.
360
- """
361
- if not _matches_gh_pr_title_subcommand(command):
362
- return None
363
- all_tokens = _parsed_command_tokens(command)
364
- if all_tokens is None:
365
- return None
366
- all_pre_tokenized = (get_logical_first_line(command), all_tokens)
367
- if _extract_flag_value(command, REPO_LONG_FLAG, REPO_SHORT_FLAG, all_pre_tokenized) is not None:
368
- return None
369
- extracted_title = _extract_flag_value(
370
- command, TITLE_LONG_FLAG, TITLE_SHORT_FLAG, all_pre_tokenized
371
- )
372
- if not extracted_title or is_unresolvable_shell_value(extracted_title):
373
- return None
374
- return extracted_title
375
-
376
-
377
- def _resolved_repo_root(payload_by_field: dict[str, object]) -> str | None:
378
- working_directory = payload_by_field.get("cwd")
379
- start_directory = (
380
- working_directory
381
- if isinstance(working_directory, str) and working_directory
382
- else str(Path.cwd())
383
- )
384
- return resolve_repo_root(start_directory)
385
-
386
-
387
- def _deny_reason(payload_by_field: dict[str, object]) -> str | None:
388
- tool_input = payload_by_field.get("tool_input", {})
389
- if not isinstance(tool_input, dict):
390
- return None
391
- command = tool_input.get("command", "")
392
- if not isinstance(command, str) or not command:
393
- return None
394
- pull_request_title = _pull_request_title_to_validate(command)
395
- if not pull_request_title or _is_conventional_commit_title(pull_request_title):
396
- return None
397
- repo_root = _resolved_repo_root(payload_by_field)
398
- if repo_root is None:
399
- return None
400
- if not _repo_enforces_default_conventional_pr_titles(repo_root):
401
- return None
402
- return CORRECTIVE_MESSAGE
403
-
404
-
405
- def main() -> None:
406
- try:
407
- hook_input = json.load(sys.stdin)
408
- except json.JSONDecodeError:
409
- sys.exit(0)
410
- if not isinstance(hook_input, dict):
411
- sys.exit(0)
412
-
413
- tool_name = hook_input.get("tool_name", "")
414
- if tool_name != BASH_TOOL_NAME:
415
- sys.exit(0)
416
-
417
- deny_reason = _deny_reason(hook_input)
418
- if deny_reason is None:
419
- sys.exit(0)
420
-
421
- deny_payload = {
422
- "hookSpecificOutput": {
423
- "hookEventName": "PreToolUse",
424
- "permissionDecision": "deny",
425
- "permissionDecisionReason": deny_reason,
426
- }
427
- }
428
- tool_input = hook_input.get("tool_input", {})
429
- command_preview = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
430
- log_hook_block(
431
- calling_hook_name="conventional_pr_title_gate.py",
432
- hook_event="PreToolUse",
433
- block_reason=deny_reason,
434
- tool_name=tool_name,
435
- offending_input_preview=command_preview,
436
- )
437
- print(json.dumps(deny_payload))
438
- sys.stdout.flush()
439
- sys.exit(0)
440
-
441
-
442
- if __name__ == "__main__":
443
- main()
@@ -1,176 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block gh commands that use --body <string> instead of --body-file.
3
-
4
- Root cause: in shell-invoked gh command contexts, passing markdown body text via
5
- --body "..." can cause backticks to be stored as literal backslash-backtick sequences
6
- on GitHub instead of rendering as inline code or code fences. Quoting and escaping
7
- rules vary by execution environment (Bash, PowerShell, CMD) but the failure mode is
8
- the same. The fix is always to write the body to a temp file and pass --body-file.
9
-
10
- Affected subcommands: gh issue create/edit/comment, gh pr create/edit/comment/review.
11
-
12
- Detection strategy: join bash \\ and PowerShell ` line continuations into a single
13
- logical line, then use shlex.split(..., posix=False) on that line so '--body'
14
- appearing inside a quoted flag value or in heredoc body content on non-continuation
15
- lines does not trigger a false positive. Both '--body value' and '--body=value' forms are blocked,
16
- as are their short '-b' equivalents. '--body-file' and '--body-file=...' are allowed.
17
- Fails CLOSED on shlex ValueError when the logical line matches an affected
18
- subcommand AND contains a bare '--body'/'-b' literal (exactly the heredoc
19
- pattern this hook must catch); otherwise approves unparseable input.
20
- """
21
-
22
- import json
23
- import re
24
- import sys
25
- from pathlib import Path
26
-
27
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
28
- if _hooks_dir not in sys.path:
29
- sys.path.insert(0, _hooks_dir)
30
-
31
- from blocking._gh_body_arg_utils import ( # noqa: E402
32
- _is_bash_continuation,
33
- all_body_flags,
34
- all_body_flag_prefixes,
35
- get_logical_first_line,
36
- iter_significant_tokens,
37
- )
38
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
39
-
40
- _GH_BODY_SUBCOMMANDS = re.compile(
41
- r"\bgh\s+(?:"
42
- r"issue\s+(?:create|edit|comment)|"
43
- r"pr\s+(?:create|edit|comment|review)"
44
- r")\b",
45
- re.IGNORECASE,
46
- )
47
-
48
- _BARE_BODY_TOKEN_PATTERN = re.compile(
49
- r"(?<!\S)(?:--body|-b)(?:=|(?![-\w]))",
50
- )
51
-
52
- _BASH_TOOL_NAME = "Bash"
53
-
54
- _CORRECTIVE_MESSAGE = (
55
- "BLOCKED [gh-body-file]: gh --body <string> escapes backticks as \\` on GitHub, "
56
- "corrupting inline code and code fences in issues, PRs, comments, and reviews. "
57
- "Write the body to a temp file and use --body-file instead.\n\n"
58
- "Safe Python pattern:\n"
59
- " import tempfile\n"
60
- " with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:\n"
61
- " f.write(body_text)\n"
62
- " body_path = f.name\n"
63
- " # then: gh ... --body-file body_path\n\n"
64
- "Safe PowerShell pattern (BOM-free, works on 5.1 and 7+):\n"
65
- " $bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')\n"
66
- " $body = @'\n"
67
- " <your markdown body>\n"
68
- " '@\n"
69
- " [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))\n"
70
- " gh ... --body-file $bodyPath\n\n"
71
- "See ~/.claude/rules/gh-cli-conventions.md for full guidance."
72
- )
73
-
74
-
75
- def _logical_line_has_bare_body_token(logical_line: str) -> bool:
76
- return bool(_BARE_BODY_TOKEN_PATTERN.search(logical_line))
77
-
78
-
79
- def _has_backtick(command: str) -> bool:
80
- """Return True if command contains a literal backtick.
81
-
82
- Joins bash `` \\ `` continuation lines so backticks in multi-line body
83
- values on later non-continuation lines are detected as part of the same
84
- logical command. Under the Bash tool — the only environment this hook
85
- intercepts — a backtick character is a command-substitution delimiter or
86
- body content; it is never a PowerShell line-continuation marker. Every
87
- backtick that survives the bash-continuation join is therefore a literal
88
- backtick that warrants the --body-file safe pattern.
89
-
90
- Scans the entire command string for backtick characters, not just --body
91
- argument content. This is intentionally conservative — any command
92
- containing backticks should use --body-file regardless of where they
93
- appear. False positives (backticks in non-body flags) are safe
94
- over-blocks.
95
- """
96
- continuation_separator = " "
97
- all_joined_lines: list[str] = []
98
- for each_line in command.splitlines():
99
- stripped_line = each_line.rstrip()
100
- if _is_bash_continuation(stripped_line):
101
- all_joined_lines.append(stripped_line[:-1].rstrip() + continuation_separator)
102
- continue
103
- all_joined_lines.append(each_line)
104
- full_logical_command = "".join(all_joined_lines)
105
- return "`" in full_logical_command
106
-
107
-
108
- def _uses_body_string_arg(command: str) -> bool:
109
- """Return True if command calls an affected gh subcommand with --body <string>.
110
-
111
- Joins bash \\ and PowerShell ` line continuations before scanning so that
112
- '--body' on a continuation line is not missed. Uses shlex.split(posix=False)
113
- for Windows-friendly tokenization: backslashes in unquoted paths are preserved
114
- and quoted values retain their surrounding quotes as part of the token, so
115
- '--body' embedded in a quoted value cannot be mistaken for a standalone flag.
116
- Detects both '--body value'/'--body=value' forms and their short '-b'
117
- equivalents. Fails CLOSED on shlex ValueError when the logical line matches
118
- an affected subcommand and contains a bare --body / -b token literal, since
119
- heredoc-wrapped --body arguments are exactly the pattern this hook exists
120
- to block; otherwise approves unparseable input (out of scope).
121
- """
122
- if not _GH_BODY_SUBCOMMANDS.search(get_logical_first_line(command)):
123
- return False
124
- try:
125
- significant_tokens = list(iter_significant_tokens(command))
126
- except ValueError:
127
- return _logical_line_has_bare_body_token(get_logical_first_line(command))
128
- for each_token, _remaining_tokens in significant_tokens:
129
- if each_token in all_body_flags:
130
- return True
131
- if any(each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes):
132
- return True
133
- return False
134
-
135
-
136
- def main() -> None:
137
- try:
138
- hook_input = json.load(sys.stdin)
139
- except json.JSONDecodeError:
140
- sys.exit(0)
141
-
142
- tool_name = hook_input.get("tool_name", "")
143
- if tool_name != _BASH_TOOL_NAME:
144
- sys.exit(0)
145
-
146
- command = hook_input.get("tool_input", {}).get("command", "")
147
- if not command:
148
- sys.exit(0)
149
-
150
- if not _uses_body_string_arg(command):
151
- sys.exit(0)
152
-
153
- if not _has_backtick(command):
154
- sys.exit(0)
155
-
156
- deny_payload = {
157
- "hookSpecificOutput": {
158
- "hookEventName": "PreToolUse",
159
- "permissionDecision": "deny",
160
- "permissionDecisionReason": _CORRECTIVE_MESSAGE,
161
- }
162
- }
163
- log_hook_block(
164
- calling_hook_name="gh_body_arg_blocker.py",
165
- hook_event="PreToolUse",
166
- block_reason=_CORRECTIVE_MESSAGE,
167
- tool_name=tool_name,
168
- offending_input_preview=command,
169
- )
170
- print(json.dumps(deny_payload))
171
- sys.stdout.flush()
172
- sys.exit(0)
173
-
174
-
175
- if __name__ == "__main__":
176
- main()