claude-dev-env 2.24.0 → 2.25.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.
@@ -9,7 +9,8 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
9
9
  | `pre_commit.py` | `pre-commit` | Runs immediate CODE_RULES and terminology validation over staged changes; exits 1 when any staged file has a blocking violation. CI runs package tests for package Python changes. |
10
10
  | `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate. An existing branch's gate base is the merge base with the remote default branch; the gate process still diffs that base against checkout HEAD, so the surface matches the pushed tip only when HEAD is that tip. |
11
11
  | `pre_push_base_reference.py` | — | Resolves a usable gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then turns a symbolic default-branch head into a reference that git can resolve |
12
- | `post_commit.py` | `post-commit` | Runs after a commit lands; performs any post-commit bookkeeping |
12
+ | `post_commit.py` | `post-commit` | Runs bookkeeping, then best-effort reminds the agent about its PR |
13
+ | `pull_request_handoff.py` | — | Prints the full PR title and description handoff prompt |
13
14
  | `gate_utils.py` | — | Shared helpers: resolves the gate script path, checks that the path is a safe regular file |
14
15
  | `test_config.py` | — | Test configuration helpers |
15
16
  | `test_gate_utils.py` | — | Tests for `gate_utils.py` |
@@ -6,14 +6,29 @@ import os
6
6
  import stat
7
7
  from pathlib import Path
8
8
 
9
+ import git_hooks_constants
9
10
  from git_hooks_constants import (
10
- ALL_GATE_SCRIPT_RELATIVE_PATH,
11
11
  CLAUDE_HOME_DEFAULT_SUBDIRECTORY,
12
12
  CLAUDE_HOME_ENV_VAR,
13
13
  GATE_PATH_OVERRIDE_ENV_VAR,
14
14
  )
15
15
 
16
16
 
17
+ def load_gate_script_relative_path(
18
+ constants_module: object | None = None,
19
+ ) -> tuple[str, ...]:
20
+ """Return the gate script path segments from git_hooks_constants.
21
+
22
+ Prefer ALL_GATE_SCRIPT_RELATIVE_PATH. Fall back to GATE_SCRIPT_RELATIVE_PATH
23
+ when an older installed constants module has not been updated yet.
24
+ """
25
+ module = git_hooks_constants if constants_module is None else constants_module
26
+ try:
27
+ return module.ALL_GATE_SCRIPT_RELATIVE_PATH
28
+ except AttributeError:
29
+ return module.GATE_SCRIPT_RELATIVE_PATH
30
+
31
+
17
32
  def resolve_gate_script_path() -> tuple[Path, Path | None]:
18
33
  """Return (gate_path, exact_allowed_override_or_none).
19
34
 
@@ -34,7 +49,7 @@ def resolve_gate_script_path() -> tuple[Path, Path | None]:
34
49
  claude_home_directory = Path(claude_home_override).resolve()
35
50
  else:
36
51
  claude_home_directory = Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY
37
- gate_path = claude_home_directory.joinpath(*ALL_GATE_SCRIPT_RELATIVE_PATH)
52
+ gate_path = claude_home_directory.joinpath(*load_gate_script_relative_path())
38
53
  return gate_path, None
39
54
 
40
55
 
@@ -97,6 +97,9 @@ GIT_REV_PARSE_VERIFY_FLAG: str = "--verify"
97
97
  GIT_QUIET_FLAG: str = "--quiet"
98
98
  GIT_SYMBOLIC_REFERENCE_SUBCOMMAND: str = "symbolic-ref"
99
99
  GIT_COMMAND_TIMEOUT_SECONDS: int = 30
100
+ GH_EXECUTABLE_NAME: str = "gh"
101
+ GH_PR_VIEW_TIMEOUT_SECONDS: int = 5
102
+ GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = ("pr", "view", "--json", "url", "--jq", ".url")
100
103
  GIT_FOR_EACH_REF_SUBCOMMAND: str = "for-each-ref"
101
104
  GIT_REFERENCE_SHORT_NAME_FORMAT_ARGUMENT: str = "--format=%(refname:short)"
102
105
  COMMIT_OBJECT_NAME_SUFFIX: str = "^{commit}"
@@ -17,7 +17,14 @@ import sys
17
17
  from enum import StrEnum
18
18
  from pathlib import Path
19
19
 
20
- from git_hooks_constants import GIT_COMMAND_SUCCESS_EXIT_CODE, GIT_EXECUTABLE_NAME
20
+ from git_hooks_constants import (
21
+ GH_EXECUTABLE_NAME,
22
+ GH_PR_VIEW_ARGUMENTS,
23
+ GH_PR_VIEW_TIMEOUT_SECONDS,
24
+ GIT_COMMAND_SUCCESS_EXIT_CODE,
25
+ GIT_EXECUTABLE_NAME,
26
+ )
27
+ from pull_request_handoff import build_pull_request_reminder
21
28
 
22
29
 
23
30
  class ParentPointerStatus(StrEnum):
@@ -163,6 +170,24 @@ def update_parent_pointer(
163
170
  return ParentPointerStatus.UPDATED, ""
164
171
 
165
172
 
173
+ def print_pull_request_reminder(repo_dir: Path) -> None:
174
+ """Print a reminder when the committed repository has an open pull request."""
175
+ try:
176
+ result = subprocess.run(
177
+ [GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
178
+ cwd=repo_dir,
179
+ check=False,
180
+ capture_output=True,
181
+ text=True,
182
+ timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
183
+ )
184
+ except (OSError, subprocess.TimeoutExpired):
185
+ return
186
+ pull_request_url = result.stdout.strip()
187
+ if result.returncode == 0 and pull_request_url:
188
+ print(build_pull_request_reminder(pull_request_url))
189
+
190
+
166
191
  def main() -> int:
167
192
  """Update a parent repository after a submodule commit."""
168
193
  repo_path_text = run_git_from_current_directory("rev-parse", "--show-toplevel")
@@ -172,12 +197,14 @@ def main() -> int:
172
197
  repo_dir = Path(repo_path_text).resolve()
173
198
  parent_repo = find_parent_repo(repo_dir)
174
199
  if parent_repo is None:
200
+ print_pull_request_reminder(repo_dir)
175
201
  return 0
176
202
 
177
203
  commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
178
204
  commit_hash = run_git("rev-parse", "HEAD", cwd=repo_dir)
179
205
  short_commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
180
206
  if not commit_hash or not short_commit_hash:
207
+ print_pull_request_reminder(repo_dir)
181
208
  return 0
182
209
 
183
210
  print()
@@ -195,14 +222,17 @@ def main() -> int:
195
222
  print("Parent update failed.")
196
223
  if parent_pointer_diagnostic:
197
224
  print(f"Git diagnostic: {parent_pointer_diagnostic}")
225
+ print_pull_request_reminder(repo_dir)
198
226
  return 0
199
227
  if parent_pointer_status is ParentPointerStatus.UNCHANGED:
200
228
  print("Parent already up to date.")
229
+ print_pull_request_reminder(repo_dir)
201
230
  return 0
202
231
 
203
232
  print("Parent updated successfully.")
204
233
  print("================================")
205
234
  print()
235
+ print_pull_request_reminder(repo_dir)
206
236
 
207
237
  return 0
208
238
 
@@ -87,14 +87,13 @@ from pre_push_base_reference import (
87
87
  resolve_remote_name_from_arguments,
88
88
  resolve_usable_base_reference,
89
89
  )
90
+ from pull_request_handoff import build_pull_request_reminder, get_pull_request_url
90
91
 
91
92
 
92
93
  def _report_unavailable_git(launch_error: Exception) -> int:
93
94
  """Report a git that would not run, and hand back the exit code to use."""
94
95
  git_command_unavailable_message = GIT_COMMAND_UNAVAILABLE_MESSAGE
95
- sys.stderr.write(
96
- git_command_unavailable_message.format(error=launch_error) + "\n"
97
- )
96
+ sys.stderr.write(git_command_unavailable_message.format(error=launch_error) + "\n")
98
97
  return GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
99
98
 
100
99
 
@@ -145,8 +144,7 @@ def is_all_zeros_object_name(object_name: str) -> bool:
145
144
  if not stripped_object_name:
146
145
  return True
147
146
  return all(
148
- each_character == all_zeros_object_name_character
149
- for each_character in stripped_object_name
147
+ each_character == all_zeros_object_name_character for each_character in stripped_object_name
150
148
  )
151
149
 
152
150
 
@@ -385,9 +383,7 @@ def resolve_default_branch_merge_base(
385
383
  default_branch_reference = resolve_default_branch_reference(remote_name)
386
384
  if default_branch_reference is None:
387
385
  return None
388
- default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(
389
- remote=remote_name, branch=""
390
- )
386
+ default_branch_prefix = REMOTE_BRANCH_REFERENCE_TEMPLATE.format(remote=remote_name, branch="")
391
387
  default_branch_name = default_branch_reference.removeprefix(default_branch_prefix)
392
388
  if remote_branch_name == default_branch_name:
393
389
  return None
@@ -514,8 +510,6 @@ def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
514
510
  return completion.returncode
515
511
 
516
512
 
517
-
518
-
519
513
  def main() -> int:
520
514
  stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
521
515
  gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
@@ -576,6 +570,9 @@ def main() -> int:
576
570
  code_rules_exit_code = invoke_gate(gate_script_path, usable_base_reference)
577
571
  if code_rules_exit_code != 0:
578
572
  return code_rules_exit_code
573
+ pull_request_url = get_pull_request_url(Path.cwd())
574
+ if pull_request_url:
575
+ print(build_pull_request_reminder(pull_request_url))
579
576
  return 0
580
577
 
581
578
 
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from git_hooks_constants import (
7
+ GH_EXECUTABLE_NAME,
8
+ GH_PR_VIEW_ARGUMENTS,
9
+ GH_PR_VIEW_TIMEOUT_SECONDS,
10
+ )
11
+
12
+
13
+ def get_pull_request_url(repo_dir: Path) -> str | None:
14
+ try:
15
+ result = subprocess.run(
16
+ [GH_EXECUTABLE_NAME, *GH_PR_VIEW_ARGUMENTS],
17
+ cwd=repo_dir,
18
+ check=False,
19
+ capture_output=True,
20
+ text=True,
21
+ timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
22
+ )
23
+ except (OSError, subprocess.SubprocessError):
24
+ return None
25
+ if result.returncode != 0:
26
+ return None
27
+ return result.stdout.strip() or None
28
+
29
+
30
+ def build_pull_request_reminder(url: str) -> str:
31
+ prompt = "Review this pull request: <PR link>\n\nRead the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.\n\nFind the main thing this adds for a user, caller, or operator.\n\nWrite like the reader is smart but knows nothing about this code. Pretend they asked you to explain it like they are five and feel stupid. If a word needs another sentence to explain it, replace the word — or turn it into a tiny story.\n\nYour job is not a dry API blurb. Your job is an illustrative before/after a person can picture.\n\nGold voice for titles and “What this adds”:\n\nThe theme package has a phone icon somewhere. We need to find it.\n\nBefore: yell “anyone know how to find icons?” and wait for a hand.\n\nAfter: call the finder by its name tag: “icon finder, go.”\n\nThat’s all “operation id” is — the finder’s name tag.\n\nRules for that voice:\n\nStart from a concrete scene (what’s in someone’s hand, what they’re hunting for).\nPrefer Before / After when the change is “how you ask” or “how you look it up.”\nUse picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.\nPrefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”\nWhen the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:\nDrop it and say what the person does with their hands, or\nKeep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.\nNever leave jargon unexplained. Never write as if the reader already knows the jargon.\nAdd a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).\nFew words. Small words. Clear on first read.\nReturn:\n\nRecommended title\n\nFew words. Small words. Clear on first read.\nSay what you hand it, what it does, and what you get back — or the Before/After of how you ask.\nPrefer picture words and spoken results.\nGood title: “Call the icon finder by its name tag instead of asking around”\nGood title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”\nBad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.\nUse “Add” only if it helps. Do not force it.\nNo vague words like “improve,” “enhance,” or “update.”\nReturn one title only.\nWhy this title fits\n\n1–2 short sentences in the same voice.\nIf needed, one Before / After beat, then what you get back.\nIf this section is needed to decode the title, rewrite the title.\nEvidence\n\nKey files or functions that support the title.\nIf you cannot read the PR or full diff, say so and do not propose a title.\n\nThen write a short, paste-ready PR description.\n\nUse this structure:\n\nWhat this adds\n1–2 short paragraphs in that illustrative voice. Lead with a scene when you can (“The theme package has a phone icon somewhere…”). If the change is a new way to ask, use Before / After. Say what you give it, what it does with its hands, and what you get back. If an abstract machine-word must appear, gloss it immediately (“That’s all X is — …”). Describe the job people can see. Do not describe inner machinery. Mention a check only if it is central to the feature. If there are many changes, keep the main one. Mention others only if they explain the main one.\n\nWhy\nOne short paragraph. Same plain level. Say who needs this and what goes wrong without it (the “yell and wait for a hand” problem). If this is needed to decode the feature, rewrite “What this adds.”\n\nVerification\nList only proof from the PR, CI, or your own checks.\n\nReported in the PR\n\n…\nChecked by you / CI here\n\n…\nRules:\n\nFew words. Small words. Clear on first read. ELI5 I-am-stupid mark.\nDo not invent behavior, tests, or benefits.\nStrip branch names, hashes, draft notes, agent notes, and merge notes.\nNo vague words like “improve,” “enhance,” or “update.”\nIllustrative first. Abstract labels only if glossed in the same breath.\nUse the recommended title and paste-ready description to run `gh pr edit <PR link> --title ... --body-file ...`, then run `gh pr view <PR link> --json title,body` to confirm the saved title and body. Report the saved result."
32
+ return prompt.replace("<PR link>", url)
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
+ import types
6
7
 
7
8
  import gate_utils
8
9
  import pytest
10
+ from git_hooks_constants import ALL_GATE_SCRIPT_RELATIVE_PATH
9
11
 
10
12
 
11
13
  def test_resolve_gate_script_path_uses_override_env_var_when_set(
@@ -218,3 +220,39 @@ def test_is_safe_regular_file_rejects_path_outside_claude_home_env_trust_root(
218
220
  is_safe = gate_utils.is_safe_regular_file(outside_path, None)
219
221
 
220
222
  assert not is_safe
223
+
224
+ def test_git_hooks_constants_exports_canonical_gate_script_relative_path() -> None:
225
+ assert ALL_GATE_SCRIPT_RELATIVE_PATH == (
226
+ "_shared",
227
+ "pr-loop",
228
+ "scripts",
229
+ "code_rules_gate.py",
230
+ )
231
+
232
+
233
+ def test_load_gate_script_relative_path_prefers_canonical_name() -> None:
234
+ constants_module = types.SimpleNamespace(
235
+ ALL_GATE_SCRIPT_RELATIVE_PATH=("_shared", "pr-loop", "scripts", "code_rules_gate.py"),
236
+ GATE_SCRIPT_RELATIVE_PATH=("legacy", "path.py"),
237
+ )
238
+
239
+ loaded_path = gate_utils.load_gate_script_relative_path(constants_module)
240
+
241
+ assert loaded_path == ("_shared", "pr-loop", "scripts", "code_rules_gate.py")
242
+
243
+
244
+ def test_load_gate_script_relative_path_falls_back_to_legacy_name() -> None:
245
+ constants_module = types.SimpleNamespace(
246
+ GATE_SCRIPT_RELATIVE_PATH=("_shared", "pr-loop", "scripts", "code_rules_gate.py"),
247
+ )
248
+
249
+ loaded_path = gate_utils.load_gate_script_relative_path(constants_module)
250
+
251
+ assert loaded_path == ("_shared", "pr-loop", "scripts", "code_rules_gate.py")
252
+
253
+
254
+ def test_gate_utils_module_import_resolves_live_constants() -> None:
255
+ loaded_path = gate_utils.load_gate_script_relative_path()
256
+
257
+ assert loaded_path == ALL_GATE_SCRIPT_RELATIVE_PATH
258
+
@@ -201,3 +201,90 @@ def test_main_prints_git_failure_diagnostic(
201
201
 
202
202
  assert post_commit.main() == 0
203
203
  assert "Git diagnostic: fatal: fixture commit failure" in capsys.readouterr().out
204
+
205
+
206
+ @pytest.mark.parametrize(
207
+ "completed_process",
208
+ [
209
+ subprocess.CompletedProcess(["gh"], 1, "", "no pull request"),
210
+ subprocess.CompletedProcess(["gh"], 1, "", "auth failed"),
211
+ ],
212
+ )
213
+ def test_pull_request_reminder_is_silent_without_successful_url(
214
+ tmp_path: Path,
215
+ monkeypatch: pytest.MonkeyPatch,
216
+ capsys: pytest.CaptureFixture[str],
217
+ completed_process: subprocess.CompletedProcess[str],
218
+ ) -> None:
219
+ monkeypatch.setattr(post_commit.subprocess, "run", lambda *args, **kwargs: completed_process)
220
+
221
+ post_commit.print_pull_request_reminder(tmp_path)
222
+
223
+ assert capsys.readouterr().out == ""
224
+
225
+
226
+ def test_pull_request_reminder_prints_url(
227
+ tmp_path: Path,
228
+ monkeypatch: pytest.MonkeyPatch,
229
+ capsys: pytest.CaptureFixture[str],
230
+ ) -> None:
231
+ monkeypatch.setattr(
232
+ post_commit.subprocess,
233
+ "run",
234
+ lambda *args, **kwargs: subprocess.CompletedProcess(
235
+ ["gh"], 0, "https://github.com/example/repo/pull/1\n", ""
236
+ ),
237
+ )
238
+
239
+ post_commit.print_pull_request_reminder(tmp_path)
240
+
241
+ output = capsys.readouterr().out
242
+ assert output.startswith("Review this pull request: https://github.com/example/repo/pull/1\n")
243
+ assert "<PR link>" not in output
244
+ assert "Do not use the branch name, commit message, labels, current title" in output
245
+ assert "gh pr edit https://github.com/example/repo/pull/1" in output
246
+ assert output.rstrip().endswith("Report the saved result.")
247
+
248
+
249
+ @pytest.mark.parametrize("exception", [FileNotFoundError(), subprocess.TimeoutExpired("gh", 5)])
250
+ def test_pull_request_reminder_ignores_missing_gh_and_timeout(
251
+ tmp_path: Path,
252
+ monkeypatch: pytest.MonkeyPatch,
253
+ capsys: pytest.CaptureFixture[str],
254
+ exception: Exception,
255
+ ) -> None:
256
+ def raise_exception(*args: object, **kwargs: object) -> None:
257
+ raise exception
258
+
259
+ monkeypatch.setattr(post_commit.subprocess, "run", raise_exception)
260
+
261
+ post_commit.print_pull_request_reminder(tmp_path)
262
+
263
+ assert capsys.readouterr().out == ""
264
+
265
+
266
+ def test_main_runs_pull_request_reminder_after_parent_update(
267
+ tmp_path: Path,
268
+ monkeypatch: pytest.MonkeyPatch,
269
+ ) -> None:
270
+ events: list[str] = []
271
+ repo = tmp_path / "repo"
272
+ parent = tmp_path / "parent"
273
+ repo.mkdir()
274
+ parent.mkdir()
275
+ monkeypatch.setattr(post_commit, "run_git_from_current_directory", lambda *args: str(repo))
276
+ monkeypatch.setattr(post_commit, "find_parent_repo", lambda repo_dir: parent)
277
+ monkeypatch.setattr(post_commit, "run_git", lambda *args, cwd: "a" * 40)
278
+ monkeypatch.setattr(
279
+ post_commit,
280
+ "update_parent_pointer",
281
+ lambda *args: events.append("parent") or (post_commit.ParentPointerStatus.UPDATED, ""),
282
+ )
283
+ monkeypatch.setattr(
284
+ post_commit,
285
+ "print_pull_request_reminder",
286
+ lambda repo_dir: events.append("reminder"),
287
+ )
288
+
289
+ assert post_commit.main() == 0
290
+ assert events == ["parent", "reminder"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.24.0",
3
+ "version": "2.25.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {