claude-dev-env 2.24.0 → 2.26.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.
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: pr-cleanup
3
+ disable-model-invocation: true
3
4
  description: >-
4
5
  Refine pull requests through parallel placement and capability-name audits,
5
6
  focused delivery sizing, and a final simplify and code-review loop. Triggers:
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: pr-plain-language-cleanup
3
+ disable-model-invocation: true
4
+ description: Review every line of every changed PR file and trim words, names, docs, comments, and code while keeping real behavior.
5
+ ---
6
+
7
+ # PR plain-language cleanup
8
+
9
+ Review and trim this pull request:
10
+
11
+ <PR LINK>
12
+
13
+ If no link is given, find the clear PR in the current context. If none is clear, ask for one.
14
+
15
+ Read the full PR, full diff, every changed file, changed tests and docs, and all repo rules that apply.
16
+
17
+ Find the changed files. Send one capable agent to each file. If there are more files than agents, use small batches.
18
+
19
+ Each agent owns one file. Read every line. Review code, tests, names, comments, docstrings, strings, notes, Markdown, and examples.
20
+
21
+ Trim filler, repeats, vague words, needless code, needless names, dead notes, jargon, and steps that add no value. Use few words and small words.
22
+
23
+ Keep real behavior, checks, data, error detail, context, tests, and public names unless a safe full rename is clear. Do not change behavior for style. Do not shorten a name when it loses meaning.
24
+
25
+ Follow the repo rules:
26
+
27
+ - Name modules for their capability.
28
+ - Name functions for their action.
29
+ - Name variables for the value they hold.
30
+ - Name classes for what they are.
31
+ - Use capability names for shared code.
32
+ - Keep workflow words on workflow code.
33
+ - Keep comments and docs true to the code.
34
+ - Use real data and real behavior in tests.
35
+
36
+ ## Hard limits
37
+
38
+ This is a non-material, text-only trim.
39
+
40
+ DO NOT:
41
+
42
+ - run `e-simplify`, `e-code-review`, or `source-command-sr-loop`;
43
+ - run `pr-cleanup`, `pr-refinement`, `pr-small-cl`, placement audits, extraction audits, or capability-name audits;
44
+ - run review loops, advisor gates, commit gates, promotion gates, or any other extra cleanup;
45
+ - hunt bugs, change behavior, change APIs, change data, add features, or split the PR;
46
+ - turn a text trim into a code fix.
47
+
48
+ Run only the smallest checks needed for the files changed. Keep the repo rules. If a material issue appears, report it and stop.
49
+
50
+ If a change needs another file, report it to the main agent. The main agent owns cross-file changes.
51
+
52
+ After the file agents finish, apply safe edits, fix cross-file names and links, search for old names and wording, and read every changed file and the full diff again.
53
+
54
+ Run only the smallest relevant check for the files changed. Fix failures. Check the final diff against the repo rules.
55
+
56
+ Do not invent behavior, tests, or benefits. Stop and ask only when a change could alter behavior, break a public API, or exceed the PR scope.
57
+
58
+ Commit and push the changes to the PR branch.
59
+
60
+ Return:
61
+
62
+ ## What changed
63
+
64
+ Short list of the main trims.
65
+
66
+ ## Files checked
67
+
68
+ Every file reviewed.
69
+
70
+ ## Verification
71
+
72
+ Checks run and results.
73
+
74
+ ## Remaining items
75
+
76
+ Only items that need a user choice.
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: pr-title-description
3
+ description: Review one or more pull requests and write plain titles and descriptions from the full diff.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ Accept one or more pull request links as arguments. Apply the prompt below to each link.
8
+
9
+ With no argument, use the current task, browser, worktree, or GitHub context to find the pull request or pull requests. If the context does not show a clear pull request, ask for a pull request link.
10
+
11
+ Review this pull request: <PR link>
12
+
13
+ Read the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.
14
+
15
+ Find the main thing this adds for a user, caller, or operator.
16
+
17
+ Write 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.
18
+
19
+ Your job is not a dry API blurb. Your job is an illustrative before/after a person can picture.
20
+
21
+ Gold voice for titles and “What this adds”:
22
+
23
+ The theme package has a phone icon somewhere. We need to find it.
24
+
25
+ Before: yell “anyone know how to find icons?” and wait for a hand.
26
+
27
+ After: call the finder by its name tag: “icon finder, go.”
28
+
29
+ That’s all “operation id” is — the finder’s name tag.
30
+
31
+ Rules for that voice:
32
+
33
+ Start from a concrete scene (what’s in someone’s hand, what they’re hunting for).
34
+ Prefer Before / After when the change is “how you ask” or “how you look it up.”
35
+ Use picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.
36
+ Prefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”
37
+ When the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:
38
+ Drop it and say what the person does with their hands, or
39
+ Keep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.
40
+ Never leave jargon unexplained. Never write as if the reader already knows the jargon.
41
+ Add a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).
42
+ Few words. Small words. Clear on first read.
43
+ Return:
44
+
45
+ Recommended title
46
+
47
+ Few words. Small words. Clear on first read.
48
+ Say what you hand it, what it does, and what you get back — or the Before/After of how you ask.
49
+ Prefer picture words and spoken results.
50
+ Good title: “Call the icon finder by its name tag instead of asking around”
51
+ Good title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”
52
+ Bad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.
53
+ Use “Add” only if it helps. Do not force it.
54
+ No vague words like “improve,” “enhance,” or “update.”
55
+ Return one title only.
56
+ Why this title fits
57
+
58
+ 1–2 short sentences in the same voice.
59
+ If needed, one Before / After beat, then what you get back.
60
+ If this section is needed to decode the title, rewrite the title.
61
+ Evidence
62
+
63
+ Key files or functions that support the title.
64
+ If you cannot read the PR or full diff, say so and do not propose a title.
65
+
66
+ Then write a short, paste-ready PR description.
67
+
68
+ Use this structure:
69
+
70
+ What this adds
71
+ 1–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.
72
+
73
+ Why
74
+ One 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.”
75
+
76
+ Verification
77
+ List only proof from the PR, CI, or your own checks.
78
+
79
+ Reported in the PR
80
+
81
+
82
+ Checked by you / CI here
83
+
84
+
85
+ Rules:
86
+
87
+ Few words. Small words. Clear on first read. ELI5 I-am-stupid mark.
88
+ Do not invent behavior, tests, or benefits.
89
+ Strip branch names, hashes, draft notes, agent notes, and merge notes.
90
+ No vague words like “improve,” “enhance,” or “update.”
91
+ Illustrative first. Abstract labels only if glossed in the same breath.
92
+ Use 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.
@@ -69,6 +69,8 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
69
69
  'midjourney-submit',
70
70
  'monitor-open-prs',
71
71
  'pr-name-by-capability',
72
+ 'pr-title-description',
73
+ 'pr-plain-language-cleanup',
72
74
  'pr-refinement',
73
75
  'orchestrator',
74
76
  'orchestrator-refresh',
@@ -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"]
@@ -148,16 +148,23 @@ def inject_session_start_context(
148
148
  )
149
149
 
150
150
 
151
- def build_additional_context_payload(injection_result: InjectionResult) -> dict[str, str]:
151
+ def build_additional_context_payload(
152
+ injection_result: InjectionResult,
153
+ ) -> dict[str, object]:
152
154
  """Return the SessionStart stdout object when context was injected.
153
155
 
154
156
  Args:
155
157
  injection_result: Result from inject_session_start_context.
156
158
 
157
159
  Returns:
158
- ``{"additionalContext": ...}`` when injected, else empty dict.
160
+ ``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
161
+ when injected, else an empty dict.
159
162
  """
160
163
  if not injection_result.is_context_injected:
161
164
  return {}
162
- return {"additionalContext": injection_result.additional_context}
163
-
165
+ return {
166
+ "hookSpecificOutput": {
167
+ "hookEventName": "SessionStart",
168
+ "additionalContext": injection_result.additional_context,
169
+ }
170
+ }
@@ -143,7 +143,10 @@ def test_build_additional_context_payload_only_when_injected() -> None:
143
143
  is_context_injected=False,
144
144
  )
145
145
  assert build_additional_context_payload(injected) == {
146
- "additionalContext": "hello"
146
+ "hookSpecificOutput": {
147
+ "hookEventName": "SessionStart",
148
+ "additionalContext": "hello",
149
+ }
147
150
  }
148
151
  assert build_additional_context_payload(empty) == {}
149
152
 
@@ -165,4 +168,3 @@ def test_default_injector_configuration_reads_env(
165
168
  monkeypatch.setenv(SESSION_START_INJECTOR_ENABLED_ENV_VAR, "off")
166
169
  configuration = default_injector_configuration()
167
170
  assert configuration.is_enabled is False
168
-
@@ -93,7 +93,7 @@ def run_issue_tracker_session_starter(
93
93
  is_enabled: bool,
94
94
  is_repository_eligible: bool,
95
95
  timeout_milliseconds: int,
96
- ) -> dict[str, str]:
96
+ ) -> dict[str, object]:
97
97
  """Return additionalContext when opt-in and repo gate both pass, else empty.
98
98
 
99
99
  Args:
@@ -103,7 +103,8 @@ def run_issue_tracker_session_starter(
103
103
  timeout_milliseconds: Injector timeout budget.
104
104
 
105
105
  Returns:
106
- ``{"additionalContext": ...}`` when injected, else ``{}``.
106
+ ``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
107
+ when injected, else ``{}``.
107
108
  """
108
109
  if not is_enabled or not is_repository_eligible:
109
110
  return {}
@@ -68,7 +68,7 @@ def run_orchestrator_auto_starter(
68
68
  payload_by_key: dict[str, object],
69
69
  is_enabled: bool,
70
70
  timeout_milliseconds: int,
71
- ) -> dict[str, str]:
71
+ ) -> dict[str, object]:
72
72
  """Return additionalContext payload when opt-in injects, else empty dict.
73
73
 
74
74
  Args:
@@ -77,7 +77,8 @@ def run_orchestrator_auto_starter(
77
77
  timeout_milliseconds: Injector timeout budget.
78
78
 
79
79
  Returns:
80
- ``{"additionalContext": ...}`` when injected, else ``{}``.
80
+ ``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
81
+ when injected, else ``{}``.
81
82
  """
82
83
  if not is_enabled:
83
84
  return {}
@@ -29,7 +29,16 @@ def build_session_directive() -> str:
29
29
 
30
30
  def main() -> None:
31
31
  """Emit the task-list loop directive as SessionStart additionalContext."""
32
- print(json.dumps({"additionalContext": build_session_directive()}))
32
+ print(
33
+ json.dumps(
34
+ {
35
+ "hookSpecificOutput": {
36
+ "hookEventName": "SessionStart",
37
+ "additionalContext": build_session_directive(),
38
+ }
39
+ }
40
+ )
41
+ )
33
42
 
34
43
 
35
44
  if __name__ == "__main__":
@@ -60,7 +60,9 @@ def test_enabled_eligible_startup_emits_directive() -> None:
60
60
  payload = run_issue_tracker_session_starter(
61
61
  {"source": "startup"}, True, True, DEFAULT_TIMEOUT
62
62
  )
63
- assert payload["additionalContext"] == ISSUE_TRACKER_SESSION_START_DIRECTIVE
63
+ hook_output = payload["hookSpecificOutput"]
64
+ assert hook_output["hookEventName"] == "SessionStart"
65
+ assert hook_output["additionalContext"] == ISSUE_TRACKER_SESSION_START_DIRECTIVE
64
66
 
65
67
 
66
68
  def test_timeout_zero_emits_nothing() -> None:
@@ -51,13 +51,15 @@ def test_enabled_startup_emits_orchestrator_directive() -> None:
51
51
  payload = run_orchestrator_auto_starter(
52
52
  {"source": "startup"}, True, DEFAULT_TIMEOUT
53
53
  )
54
- assert payload["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
54
+ hook_output = payload["hookSpecificOutput"]
55
+ assert hook_output["hookEventName"] == "SessionStart"
56
+ assert hook_output["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
55
57
 
56
58
 
57
59
  @pytest.mark.parametrize("source", ("startup", "resume", "clear", "compact"))
58
60
  def test_enabled_known_sources_emit_directive(source: str) -> None:
59
61
  payload = run_orchestrator_auto_starter({"source": source}, True, DEFAULT_TIMEOUT)
60
- assert ORCHESTRATOR_SESSION_START_DIRECTIVE in payload.get("additionalContext", "")
62
+ assert ORCHESTRATOR_SESSION_START_DIRECTIVE in payload["hookSpecificOutput"]["additionalContext"]
61
63
 
62
64
 
63
65
  def test_enabled_unknown_source_emits_nothing() -> None:
@@ -81,7 +83,7 @@ def test_main_stdin_enabled_prints_json() -> None:
81
83
  )
82
84
  assert completed.returncode == 0
83
85
  parsed = json.loads(completed.stdout)
84
- assert parsed["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
86
+ assert parsed["hookSpecificOutput"]["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
85
87
 
86
88
 
87
89
  def test_main_stdin_disabled_prints_nothing() -> None:
@@ -31,31 +31,33 @@ def _run_main() -> str:
31
31
  class TestSessionDirective:
32
32
  def test_main_emits_additional_context(self) -> None:
33
33
  emitted = json.loads(_run_main())
34
- assert "additionalContext" in emitted
34
+ hook_output = emitted["hookSpecificOutput"]
35
+ assert hook_output["hookEventName"] == "SessionStart"
36
+ assert "additionalContext" in hook_output
35
37
 
36
38
  def test_directive_carries_the_one_line_instruction(self) -> None:
37
39
  emitted = json.loads(_run_main())
38
- assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["additionalContext"]
40
+ assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["hookSpecificOutput"]["additionalContext"]
39
41
 
40
42
  def test_directive_names_the_ten_minute_cadence(self) -> None:
41
43
  emitted = json.loads(_run_main())
42
- assert "10-minute" in emitted["additionalContext"]
44
+ assert "10-minute" in emitted["hookSpecificOutput"]["additionalContext"]
43
45
 
44
46
  def test_directive_names_the_loop_command(self) -> None:
45
47
  emitted = json.loads(_run_main())
46
- assert "/loop 10m" in emitted["additionalContext"]
48
+ assert "/loop 10m" in emitted["hookSpecificOutput"]["additionalContext"]
47
49
 
48
50
  def test_directive_runs_the_instruction_immediately(self) -> None:
49
51
  emitted = json.loads(_run_main())
50
52
  assert "Run that instruction once immediately when starting the loop, then" in emitted[
51
- "additionalContext"
52
- ]
53
+ "hookSpecificOutput"
54
+ ]["additionalContext"]
53
55
 
54
56
  def test_directive_reuses_an_existing_loop(self) -> None:
55
57
  emitted = json.loads(_run_main())
56
58
  assert "Reuse the active task-list maintenance loop when available." in emitted[
57
- "additionalContext"
58
- ]
59
+ "hookSpecificOutput"
60
+ ]["additionalContext"]
59
61
 
60
62
  def test_build_session_directive_returns_the_shared_constant(self) -> None:
61
63
  assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE
@@ -99,7 +99,9 @@ class TestUntrackedRepoDetection:
99
99
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
100
100
  assert stdout.strip() != ""
101
101
  emitted = json.loads(stdout)
102
- assert "additionalContext" in emitted
102
+ hook_output = emitted["hookSpecificOutput"]
103
+ assert hook_output["hookEventName"] == "SessionStart"
104
+ assert "additionalContext" in hook_output
103
105
 
104
106
  def test_emitted_context_names_the_detected_repo_path(self, tmp_path: Path) -> None:
105
107
  repo_root = tmp_path / "new-repo"
@@ -107,7 +109,7 @@ class TestUntrackedRepoDetection:
107
109
  (repo_root / ".git").mkdir()
108
110
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
109
111
  emitted = json.loads(stdout)
110
- context_text = emitted["additionalContext"]
112
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
111
113
  assert str(repo_root) in context_text
112
114
 
113
115
  def test_emitted_context_names_the_config_file_path(self, tmp_path: Path) -> None:
@@ -116,7 +118,7 @@ class TestUntrackedRepoDetection:
116
118
  (repo_root / ".git").mkdir()
117
119
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
118
120
  emitted = json.loads(stdout)
119
- context_text = emitted["additionalContext"]
121
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
120
122
  assert "project-paths.json" in context_text
121
123
 
122
124
  def test_emitted_context_instructs_claude_to_use_ask_user_question(
@@ -127,7 +129,7 @@ class TestUntrackedRepoDetection:
127
129
  (repo_root / ".git").mkdir()
128
130
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
129
131
  emitted = json.loads(stdout)
130
- context_text = emitted["additionalContext"]
132
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
131
133
  assert "AskUserQuestion" in context_text
132
134
 
133
135
  def test_emitted_context_states_hook_has_written_nothing(
@@ -138,7 +140,7 @@ class TestUntrackedRepoDetection:
138
140
  (repo_root / ".git").mkdir()
139
141
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
140
142
  emitted = json.loads(stdout)
141
- context_text = emitted["additionalContext"]
143
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
142
144
  assert (
143
145
  "written nothing" in context_text.lower()
144
146
  or "has not written" in context_text.lower()
@@ -30,15 +30,17 @@ def _run_main() -> str:
30
30
  class TestWorkingStylePrompt:
31
31
  def test_main_emits_additional_context(self) -> None:
32
32
  emitted = json.loads(_run_main())
33
- assert "additionalContext" in emitted
33
+ hook_output = emitted["hookSpecificOutput"]
34
+ assert hook_output["hookEventName"] == "SessionStart"
35
+ assert "additionalContext" in hook_output
34
36
 
35
37
  def test_additional_context_matches_prompt_exactly(self) -> None:
36
38
  emitted = json.loads(_run_main())
37
- assert emitted["additionalContext"] == WORKING_STYLE_PROMPT
39
+ assert emitted["hookSpecificOutput"]["additionalContext"] == WORKING_STYLE_PROMPT
38
40
 
39
41
  def test_emitted_prompt_contains_canonical_policy_and_scope_guidance(self) -> None:
40
42
  emitted = json.loads(_run_main())
41
- prompt_text = emitted["additionalContext"]
43
+ prompt_text = emitted["hookSpecificOutput"]["additionalContext"]
42
44
  assert "Document each task in a location that remains easy to find later." in prompt_text
43
45
  assert "Deliver the requested work at its intended scope." in prompt_text
44
46
  assert "Use ELI5 for beginner framing, large visuals, minimal text" in prompt_text
@@ -64,7 +64,16 @@ def main() -> None:
64
64
  if registry_contains_path(known_registry, git_root):
65
65
  sys.exit(0)
66
66
  instruction = _build_confirm_instruction(git_root)
67
- print(json.dumps({"additionalContext": instruction}))
67
+ print(
68
+ json.dumps(
69
+ {
70
+ "hookSpecificOutput": {
71
+ "hookEventName": "SessionStart",
72
+ "additionalContext": instruction,
73
+ }
74
+ }
75
+ )
76
+ )
68
77
  except Exception as e:
69
78
  _logger.error("%s", e)
70
79
  sys.exit(0)
@@ -31,7 +31,16 @@ def build_session_directive() -> str:
31
31
 
32
32
  def main() -> None:
33
33
  """Emit the working-style prompt as SessionStart additionalContext."""
34
- print(json.dumps({"additionalContext": build_session_directive()}))
34
+ print(
35
+ json.dumps(
36
+ {
37
+ "hookSpecificOutput": {
38
+ "hookEventName": "SessionStart",
39
+ "additionalContext": build_session_directive(),
40
+ }
41
+ }
42
+ )
43
+ )
35
44
 
36
45
 
37
46
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.24.0",
3
+ "version": "2.26.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@ reparse_point_attribute_name = "FILE_ATTRIBUTE_REPARSE_POINT"
25
25
  manifest_indentation_width = 2
26
26
  publish_plan_max_positional_arguments = 3
27
27
  publish_plan_failure_injector_position = 2
28
- frontmatter_unsupported_fields = ("tools", "model", "color")
28
+ frontmatter_unsupported_fields = ("tools", "model", "color", "disable-model-invocation")
29
29
  instruction_alias_filenames = frozenset({"AGENTS.md", "CLAUDE.md"})
30
30
  failure_blast_radius_rule_relative_path = "rules/failure-blast-radius.md"
31
31
  codex_instruction_target_path = "AGENTS.md"
@@ -152,7 +152,9 @@ report_categories = (
152
152
  "stale_managed", "deleted", "unsupported", "conflicted", "errors",
153
153
  )
154
154
  report_categories_public_name = "REPORT_CATEGORIES"
155
- frontmatter_allowed_fields = {"name", "description", "tools", "model", "color"}
155
+ frontmatter_allowed_fields = {
156
+ "name", "description", "tools", "model", "color", "disable-model-invocation"
157
+ }
156
158
  line_separator = "\n"
157
159
  comma_separator = ", "
158
160
 
@@ -40,8 +40,8 @@ def test_conversion_escapes_toml_content() -> None:
40
40
  def test_malformed_and_unsupported_frontmatter() -> None:
41
41
  with pytest.raises(MaterializerError):
42
42
  parse_frontmatter(Path("bad.md"), "---\nname: x\n", "bad.md")
43
- agent = parse_frontmatter(Path("ok.md"), "---\nname: x\ndescription: y\nmodel: sonnet\ncolor: blue\n---\n", "ok.md")
44
- assert agent.unsupported == ("color", "model")
43
+ agent = parse_frontmatter(Path("ok.md"), "---\nname: x\ndescription: y\nmodel: sonnet\ncolor: blue\ndisable-model-invocation: true\n---\n", "ok.md")
44
+ assert agent.unsupported == ("color", "disable-model-invocation", "model")
45
45
 
46
46
 
47
47
  @pytest.mark.parametrize(