claude-dev-env 8.69.0 → 8.70.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 (99) hide show
  1. package/.agents/skills/correction/SKILL.md +28 -6
  2. package/.agents/skills/correction/references/filing.md +33 -0
  3. package/.agents/skills/correction/scripts/correction_filing.py +327 -0
  4. package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
  5. package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
  6. package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
  7. package/.agents/skills/correction/scripts/pytest.ini +4 -0
  8. package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
  9. package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
  10. package/.agents/skills/second-account-workers/SKILL.md +21 -3
  11. package/docs/rule-guides/features-start-with-an-eval.md +40 -0
  12. package/hooks/blocking/followup_pr_dedupe.py +55 -5
  13. package/hooks/blocking/image_read_size_gate.py +167 -0
  14. package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
  15. package/hooks/blocking/pull_request_proof.py +86 -20
  16. package/hooks/blocking/test_artifact_look_gate.py +1 -0
  17. package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
  18. package/hooks/blocking/test_image_read_size_gate.py +79 -0
  19. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
  21. package/hooks/blocking/test_pull_request_proof.py +124 -1
  22. package/hooks/features/README.md +1 -0
  23. package/hooks/features/conduct-gates.md +1 -1
  24. package/hooks/features/read-gates.md +20 -0
  25. package/hooks/features/write-edit-blocking.md +3 -5
  26. package/hooks/hooks.json +11 -1
  27. package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
  28. package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
  29. package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
  30. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
  31. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
  32. package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
  33. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
  34. package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
  35. package/hooks/test_transcript_skill_scan.py +75 -1
  36. package/hooks/transcript_skill_scan.py +87 -15
  37. package/package.json +1 -1
  38. package/rules/anti-corollary-tests.md +2 -2
  39. package/rules/archiving-agent-config.md +2 -2
  40. package/rules/asd-ste100-language.md +2 -2
  41. package/rules/bdd.md +1 -1
  42. package/rules/build-needs-a-user.md +1 -1
  43. package/rules/cleanup-temp-files.md +3 -3
  44. package/rules/code-standards.md +1 -1
  45. package/rules/correction-lens.md +3 -3
  46. package/rules/destructive-commands.md +3 -3
  47. package/rules/doc-inventory-integrity.md +1 -1
  48. package/rules/docstring-prose-matches-implementation.md +1 -1
  49. package/rules/explore-thoroughly.md +1 -1
  50. package/rules/failure-blast-radius.md +1 -1
  51. package/rules/falsify-before-green.md +2 -2
  52. package/rules/features-start-with-an-eval.md +9 -0
  53. package/rules/filesystem-search.md +3 -3
  54. package/rules/flag-non-breaking-findings.md +2 -2
  55. package/rules/long-horizon-autonomy.md +2 -2
  56. package/rules/memory-stores-durable-facts.md +2 -2
  57. package/rules/no-contrast-framing.md +1 -1
  58. package/rules/no-cross-skill-duplicate-helpers.md +1 -1
  59. package/rules/orphan-css-class.md +1 -1
  60. package/rules/paired-test-coverage.md +1 -1
  61. package/rules/plain-illustrative-docstrings.md +1 -1
  62. package/rules/prefer-existing-tools.md +2 -2
  63. package/rules/proof-before-pull-request.md +1 -1
  64. package/rules/request-scope.md +1 -1
  65. package/rules/research-mode.md +2 -2
  66. package/rules/retired-hook-prose.md +1 -1
  67. package/rules/session-title.md +1 -1
  68. package/rules/shell-invocation.md +2 -2
  69. package/rules/testing.md +1 -1
  70. package/rules/verify-before-asking.md +3 -3
  71. package/rules/verify-runtime-state.md +2 -2
  72. package/rules/windows-filesystem-safe.md +1 -1
  73. package/rules/workers-done-before-complete.md +2 -2
  74. package/rules/workflow-substitution-slots.md +1 -1
  75. package/scripts/account_broker.py +10 -3
  76. package/scripts/account_broker_support.py +7 -3
  77. package/scripts/agent_image_copy.py +155 -0
  78. package/scripts/claude_account_worker.py +24 -5
  79. package/scripts/claude_account_worker_process.py +5 -0
  80. package/scripts/claude_account_worker_report.py +16 -1
  81. package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
  82. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
  83. package/scripts/policy_lint/config/constants.py +1 -0
  84. package/scripts/test_account_broker.py +38 -0
  85. package/scripts/test_account_broker_support.py +24 -0
  86. package/scripts/test_agent_image_copy.py +76 -0
  87. package/scripts/test_claude_account_worker.py +47 -0
  88. package/scripts/test_claude_account_worker_process.py +20 -0
  89. package/scripts/test_claude_account_worker_report.py +15 -0
  90. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
  91. package/scripts/tests/test_rule_load_scopes.py +1 -0
  92. package/hooks/advisory/conftest.py +0 -10
  93. package/hooks/advisory/refactor_guard.py +0 -385
  94. package/hooks/advisory/refactor_guard_test_support.py +0 -46
  95. package/hooks/advisory/test_refactor_guard.py +0 -148
  96. package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
  97. package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
  98. package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
  99. package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
@@ -1,4 +1,4 @@
1
- """Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section."""
1
+ """Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section, or a feature pull request whose body lacks an 'Eval' section."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,13 +6,14 @@ import re
6
6
  import subprocess
7
7
  import sys
8
8
  from collections.abc import Mapping
9
+ from typing import NamedTuple
9
10
  from pathlib import Path
10
11
 
11
12
  hooks_directory = str(Path(__file__).resolve().parent.parent)
12
13
  if hooks_directory not in sys.path:
13
14
  sys.path.insert(0, hooks_directory)
14
15
 
15
- from blocking.followup_pr_dedupe import body_from_arguments
16
+ from blocking.followup_pr_dedupe import body_from_arguments, create_tool_fields, title_from_arguments
16
17
  from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
17
18
  from hooks_constants.pull_request_proof_constants import (
18
19
  ALL_GH_CREATE_WORDS,
@@ -20,16 +21,18 @@ from hooks_constants.pull_request_proof_constants import (
20
21
  ALL_VISIBLE_FILE_SUFFIXES,
21
22
  CHANGED_FILES_SEPARATOR,
22
23
  COMMAND_MARKER,
23
- CREATE_PULL_REQUEST_TOOL_SUFFIX,
24
24
  ALL_DEFAULT_BRANCH_REFERENCES,
25
25
  ALL_HEAD_FLAGS,
26
26
  HEAD_FLAG_ASSIGNMENT_PREFIX,
27
27
  REMOTE_BRANCH_PREFIX,
28
28
  UNREADABLE_CHANGES_REASON,
29
+ EVAL_HEADING_PATTERN,
29
30
  EXISTING_WORK_HEADING_PATTERN,
31
+ FEATURE_TITLE_PATTERN,
30
32
  GIT_TIMEOUT_SECONDS,
31
33
  LINE_SEPARATOR,
32
34
  MISSING_BODY_REASON,
35
+ MISSING_EVAL_SECTION_REASON,
33
36
  MISSING_EXISTING_WORK_REASON,
34
37
  MISSING_LOOK_REASON,
35
38
  MISSING_PROOF_REASON,
@@ -48,6 +51,13 @@ from hooks_constants.shell_command_wrappers import (
48
51
  )
49
52
 
50
53
 
54
+ class CreateCall(NamedTuple):
55
+ """The title and body one pull request create call passes; None marks a value this gate cannot read."""
56
+
57
+ title: str | None
58
+ body: str | None
59
+
60
+
51
61
  def _heading_level(line: str, heading_pattern: str) -> int | None:
52
62
  heading = re.match(heading_pattern, line.strip(), re.IGNORECASE)
53
63
  return None if heading is None else len(heading.group(1))
@@ -120,9 +130,12 @@ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
120
130
  return _script_create_arguments(program, all_arguments)
121
131
 
122
132
 
123
- def _shell_create_bodies(command: str, working_directory: str) -> list[str | None]:
133
+ def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall]:
124
134
  return [
125
- body_from_arguments(all_create_arguments, working_directory)
135
+ CreateCall(
136
+ title_from_arguments(all_create_arguments),
137
+ body_from_arguments(all_create_arguments, working_directory),
138
+ )
126
139
  for each_text in all_wrapped_command_texts(command)
127
140
  for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
128
141
  if (all_create_arguments := _create_arguments(each_segment)) is not None
@@ -139,13 +152,12 @@ def _head_flag_value(all_create_arguments: list[str]) -> str | None:
139
152
 
140
153
 
141
154
  def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
142
- tool_input = all_payload_fields.get("tool_input")
143
- if not isinstance(tool_input, dict):
144
- return None
145
- head_branch = tool_input.get("head")
155
+ create_fields = create_tool_fields(all_payload_fields)
156
+ head_branch = None if create_fields is None else create_fields.get("head")
146
157
  if isinstance(head_branch, str):
147
158
  return head_branch
148
- command = tool_input.get("command")
159
+ tool_input = all_payload_fields.get("tool_input")
160
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
149
161
  if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
150
162
  return None
151
163
  all_head_flag_values = [
@@ -157,23 +169,77 @@ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
157
169
  return next((each_value for each_value in all_head_flag_values if each_value), None)
158
170
 
159
171
 
160
- def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
161
- tool_name = all_payload_fields.get("tool_name")
172
+ def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
173
+ create_fields = create_tool_fields(all_payload_fields)
174
+ if create_fields is not None:
175
+ title = create_fields.get("title")
176
+ body = create_fields.get("body")
177
+ return [
178
+ CreateCall(
179
+ title if isinstance(title, str) else None,
180
+ body if isinstance(body, str) else "",
181
+ )
182
+ ]
162
183
  tool_input = all_payload_fields.get("tool_input")
163
- if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
164
- return []
165
- if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
166
- body = tool_input.get("body")
167
- return [body if isinstance(body, str) else ""]
168
- command = tool_input.get("command")
169
- if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
184
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
185
+ if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
170
186
  return []
171
187
  working_directory = all_payload_fields.get("cwd")
172
- return _shell_create_bodies(
188
+ return _shell_create_calls(
173
189
  command, working_directory if isinstance(working_directory, str) else ""
174
190
  )
175
191
 
176
192
 
193
+ def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
194
+ return [each_call.body for each_call in _create_calls(all_payload_fields)]
195
+
196
+
197
+ def _is_feature_title(title: str | None) -> bool:
198
+ return title is not None and re.match(FEATURE_TITLE_PATTERN, title, re.IGNORECASE) is not None
199
+
200
+
201
+ def is_feature_pull_request(all_payload_fields: Mapping[str, object]) -> bool:
202
+ """Return whether any pull request create call in the payload carries a feature title.
203
+
204
+ ::
205
+
206
+ mcp__github__create_pull_request, title "feat(hooks)!: x" -> True
207
+ gh pr create --title "fix: y" --body-file b.md -> False
208
+ gh pr create --fill -> False
209
+ gh pr edit, git push, any other tool -> False
210
+
211
+ Args:
212
+ all_payload_fields: The parsed PreToolUse input.
213
+ """
214
+ return any(_is_feature_title(each_call.title) for each_call in _create_calls(all_payload_fields))
215
+
216
+
217
+ def missing_eval_section_reason(all_payload_fields: Mapping[str, object]) -> str | None:
218
+ """Return a deny reason when a feature pull request's body has no eval section, else None.
219
+
220
+ ::
221
+
222
+ title "feat: x", body has the section -> None
223
+ title "feat: x", body without it -> MISSING_EVAL_SECTION_REASON
224
+ title "fix: y", body without it -> None
225
+ title "feat: x", gh pr create --fill -> None
226
+
227
+ The section is an 'Eval' heading whose text names at least one command in
228
+ backticks. A create command whose body this gate cannot read passes here,
229
+ because missing_proof_reason denies it first.
230
+
231
+ Args:
232
+ all_payload_fields: The parsed PreToolUse input.
233
+ """
234
+ for each_call in _create_calls(all_payload_fields):
235
+ if each_call.body is None or not _is_feature_title(each_call.title):
236
+ continue
237
+ section = _section_text(each_call.body, EVAL_HEADING_PATTERN)
238
+ if not section or COMMAND_MARKER not in section:
239
+ return MISSING_EVAL_SECTION_REASON
240
+ return None
241
+
242
+
177
243
  def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None:
178
244
  """Return a deny reason when a new pull request's body has no proof, else None.
179
245
 
@@ -101,6 +101,7 @@ def test_should_refuse_a_page_published_without_a_look(tmp_path: Path) -> None:
101
101
  specific_output = hook_output["hookSpecificOutput"]
102
102
  assert specific_output["permissionDecision"] == "deny"
103
103
  assert "--screenshot" in specific_output["permissionDecisionReason"]
104
+ assert "Attach" not in specific_output["permissionDecisionReason"]
104
105
 
105
106
 
106
107
  def test_should_allow_a_page_whose_newer_screenshot_was_read(tmp_path: Path) -> None:
@@ -92,3 +92,36 @@ def test_body_from_arguments_reads_inline_text_and_body_files(tmp_path: Path) ->
92
92
  assert dedupe.body_from_arguments(["--body-file", "body.md"], str(tmp_path)) == "From the file."
93
93
  assert dedupe.body_from_arguments(["--body-file", "-"], str(tmp_path)) is None
94
94
  assert dedupe.body_from_arguments(["--fill"], str(tmp_path)) is None
95
+
96
+
97
+ def test_title_from_arguments_should_read_each_title_flag_shape() -> None:
98
+ assert dedupe.title_from_arguments(["--title", "feat: x", "--fill"]) == "feat: x"
99
+ assert dedupe.title_from_arguments(["-t", "fix: y"]) == "fix: y"
100
+ assert dedupe.title_from_arguments(["--title=feat(x): z"]) == "feat(x): z"
101
+ assert dedupe.title_from_arguments(["--title-file", "t.txt"]) is None
102
+ assert dedupe.title_from_arguments(["--fill"]) is None
103
+
104
+
105
+ def _dispatch_payload(all_inputs: dict[str, object]) -> dict[str, object]:
106
+ return {
107
+ "tool_name": "mcp__github__actions_run_trigger",
108
+ "tool_input": {"method": "run_workflow", "owner": "jl-cmd", "repo": "claude-dev-env", "inputs": all_inputs},
109
+ }
110
+
111
+
112
+ def test_create_tool_fields_should_read_a_pull_request_dispatch_as_a_create() -> None:
113
+ payload = _dispatch_payload({"head": "b", "title": "fix: y", "body": "Follow-up to #1731"})
114
+ assert dedupe.create_tool_fields(payload) == {
115
+ "head": "b",
116
+ "title": "fix: y",
117
+ "body": "Follow-up to #1731",
118
+ "owner": "jl-cmd",
119
+ "repo": "claude-dev-env",
120
+ }
121
+ reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
122
+ assert reason is not None and "#1769" in reason
123
+
124
+
125
+ def test_create_tool_fields_should_skip_a_call_that_opens_no_pull_request() -> None:
126
+ assert dedupe.create_tool_fields(_dispatch_payload({"work_order": "wo-1"})) is None
127
+ assert dedupe.create_tool_fields({"tool_name": "Bash", "tool_input": {"command": "gh pr list"}}) is None
@@ -0,0 +1,79 @@
1
+ """Behavior tests for the image read size gate."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from PIL import Image
10
+
11
+ _BLOCKING_DIR = Path(__file__).resolve().parent
12
+ _HOOKS_ROOT = _BLOCKING_DIR.parent
13
+ for each_sys_path_entry in (str(_BLOCKING_DIR), str(_HOOKS_ROOT)):
14
+ if each_sys_path_entry not in sys.path:
15
+ sys.path.insert(0, each_sys_path_entry)
16
+
17
+ import image_read_size_gate
18
+
19
+ HOOK_SCRIPT = _BLOCKING_DIR / "image_read_size_gate.py"
20
+
21
+
22
+ def _image(file_path: Path, width: int, height: int) -> Path:
23
+ Image.new("RGB", (width, height), "white").save(file_path)
24
+ return file_path
25
+
26
+
27
+ def _run_hook(file_path: Path) -> str:
28
+ completed = subprocess.run(
29
+ [sys.executable, str(HOOK_SCRIPT)],
30
+ input=json.dumps(
31
+ {
32
+ "hook_event_name": "PreToolUse",
33
+ "tool_name": "Read",
34
+ "tool_input": {"file_path": str(file_path)},
35
+ }
36
+ ),
37
+ capture_output=True,
38
+ text=True,
39
+ check=True,
40
+ )
41
+ return completed.stdout
42
+
43
+
44
+ def test_should_refuse_a_full_size_preview_read(tmp_path: Path) -> None:
45
+ hook_output = json.loads(_run_hook(_image(tmp_path / "preview.png", 1440, 2560)))
46
+ specific_output = hook_output["hookSpecificOutput"]
47
+ assert specific_output["permissionDecision"] == "deny"
48
+ assert "1440x2560" in specific_output["permissionDecisionReason"]
49
+ assert "agent_image_copy.py" in specific_output["permissionDecisionReason"]
50
+
51
+
52
+ def test_should_allow_an_image_at_the_cap(tmp_path: Path) -> None:
53
+ assert _run_hook(_image(tmp_path / "preview.agent.png", 288, 512)) == ""
54
+
55
+
56
+ def test_should_allow_a_text_file(tmp_path: Path) -> None:
57
+ notes_path = tmp_path / "notes.md"
58
+ notes_path.write_text("x" * 4000, encoding="utf-8")
59
+ assert _run_hook(notes_path) == ""
60
+
61
+
62
+ def test_should_allow_a_missing_file(tmp_path: Path) -> None:
63
+ assert _run_hook(tmp_path / "gone.png") == ""
64
+
65
+
66
+ @pytest.mark.parametrize("suffix", [".png", ".jpg", ".gif"])
67
+ def test_image_size_reads_each_format(tmp_path: Path, suffix: str) -> None:
68
+ image_path = _image(tmp_path / f"wide{suffix}", 900, 300)
69
+ assert image_read_size_gate.image_size(image_path.read_bytes()[:65536]) == (900, 300)
70
+
71
+
72
+ def test_oversized_image_reason_passes_other_tools(tmp_path: Path) -> None:
73
+ image_path = _image(tmp_path / "big.png", 2000, 2000)
74
+ assert (
75
+ image_read_size_gate.oversized_image_reason(
76
+ {"tool_name": "Write", "tool_input": {"file_path": str(image_path)}}
77
+ )
78
+ is None
79
+ )
@@ -14,7 +14,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
14
14
  from blocking import followup_pr_dedupe as gate_dedupe
15
15
  from blocking import pr_lifecycle_skill_gate as gate
16
16
  from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
17
- from hooks_constants.pull_request_proof_constants import MISSING_PROOF_REASON
17
+ from hooks_constants.pull_request_proof_constants import MISSING_BUILD_EVAL_REASON, MISSING_PROOF_REASON
18
18
 
19
19
  PROVEN_FOLLOWUP_BODY = (
20
20
  "Follow-up to #1731\n\n## Existing work\nNothing found in open or merged pull requests.\n\n"
@@ -225,11 +225,11 @@ def test_hook_has_its_own_pre_tool_use_registration() -> None:
225
225
  each_group
226
226
  for each_group in all_groups
227
227
  if each_group["matcher"]
228
- == "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)"
228
+ == "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)"
229
229
  ]
230
230
  assert matching_groups == [
231
231
  {
232
- "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
232
+ "matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)",
233
233
  "hooks": [
234
234
  {
235
235
  "type": "command",
@@ -283,3 +283,117 @@ def test_loaded_skill_still_denies_a_new_pull_request_without_proof(tmp_path: Pa
283
283
  exit_code, output = _run_main(_payload("gh pr create --body 'Adds a gate.'", _transcript(tmp_path, "pr-lifecycle")))
284
284
  assert exit_code == 0
285
285
  assert json.loads(output)["hookSpecificOutput"]["permissionDecisionReason"] == MISSING_PROOF_REASON
286
+
287
+
288
+ EVALUATED_BODY = (
289
+ "## Existing work\nNothing found in open or merged pull requests.\n\n"
290
+ "## Proof in practice\nRan `python probe.py`.\n\n"
291
+ "## Eval\nTen labeled cases, exact-match grader. Ran `python eval.py`: 10/10.\n"
292
+ )
293
+ SKILL_LOAD_LINE = json.dumps(
294
+ {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "pr-lifecycle"}}]}}
295
+ )
296
+ BUILD_EVAL_LINE = json.dumps(
297
+ {
298
+ "type": "assistant",
299
+ "message": {
300
+ "content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "claude-api", "args": "build-eval"}}]
301
+ },
302
+ }
303
+ )
304
+
305
+
306
+ def _transcript_with_lines(tmp_path: Path, file_name: str, all_lines: list[str]) -> Path:
307
+ transcript_path = tmp_path / file_name
308
+ transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
309
+ return transcript_path
310
+
311
+
312
+ def _create_payload(tmp_path: Path, title: str, transcript_path: Path) -> dict[str, object]:
313
+ return {
314
+ "tool_name": "mcp__github__create_pull_request",
315
+ "tool_input": {"owner": "o", "repo": "r", "title": title, "body": EVALUATED_BODY},
316
+ "transcript_path": str(transcript_path),
317
+ "cwd": _checkout_without_visible_changes(tmp_path),
318
+ }
319
+
320
+
321
+ def _assert_denied_with(payload: dict[str, object], reason: str) -> None:
322
+ decision = gate.decision_for(payload)
323
+ assert decision is not None
324
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
325
+ assert decision["hookSpecificOutput"]["permissionDecisionReason"] == reason
326
+
327
+
328
+ def test_feature_pull_request_without_build_eval_invocation_should_be_denied(tmp_path: Path) -> None:
329
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
330
+ _assert_denied_with(_create_payload(tmp_path, "feat: add a gate", transcript_path), MISSING_BUILD_EVAL_REASON)
331
+
332
+
333
+ def test_feature_pull_request_with_build_eval_invocation_should_pass(tmp_path: Path) -> None:
334
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE, SKILL_LOAD_LINE])
335
+ assert gate.decision_for(_create_payload(tmp_path, "feat: add a gate", transcript_path)) is None
336
+
337
+
338
+ def test_non_feature_pull_request_without_build_eval_invocation_should_pass(tmp_path: Path) -> None:
339
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
340
+ assert gate.decision_for(_create_payload(tmp_path, "fix: repair a gate", transcript_path)) is None
341
+
342
+
343
+ def test_feature_pull_request_with_no_readable_transcript_should_pass(tmp_path: Path) -> None:
344
+ payload = _create_payload(tmp_path, "feat: add a gate", tmp_path / "missing.jsonl")
345
+ assert gate.decision_for(payload) is None
346
+
347
+
348
+ def test_build_eval_check_should_ignore_an_unreadable_transcript_beside_a_readable_one(tmp_path: Path) -> None:
349
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
350
+ payload = _create_payload(tmp_path, "feat: add a gate", transcript_path)
351
+ payload["agent_transcript_path"] = str(tmp_path / "missing-agent.jsonl")
352
+ _assert_denied_with(payload, MISSING_BUILD_EVAL_REASON)
353
+
354
+
355
+ def test_build_eval_invocation_in_the_agent_transcript_should_pass(tmp_path: Path) -> None:
356
+ session_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
357
+ agent_path = _transcript_with_lines(tmp_path, "agent.jsonl", [BUILD_EVAL_LINE])
358
+ payload = _create_payload(tmp_path, "feat: add a gate", session_path)
359
+ payload["agent_transcript_path"] = str(agent_path)
360
+ assert gate.decision_for(payload) is None
361
+
362
+
363
+ def _dispatch_payload(tmp_path: Path, all_inputs: dict[str, object], transcript_path: Path) -> dict[str, object]:
364
+ return {
365
+ "tool_name": "mcp__github__actions_run_trigger",
366
+ "tool_input": {
367
+ "method": "run_workflow",
368
+ "owner": "o",
369
+ "repo": "r",
370
+ "workflow_id": "open.yml",
371
+ "ref": "main",
372
+ "inputs": all_inputs,
373
+ },
374
+ "transcript_path": str(transcript_path),
375
+ "cwd": _checkout_without_visible_changes(tmp_path),
376
+ }
377
+
378
+
379
+ def test_feature_dispatch_without_build_eval_invocation_should_be_denied(tmp_path: Path) -> None:
380
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
381
+ all_inputs = {"head": "main", "title": "feat: add a gate", "body": EVALUATED_BODY}
382
+ _assert_denied_with(_dispatch_payload(tmp_path, all_inputs, transcript_path), MISSING_BUILD_EVAL_REASON)
383
+
384
+
385
+ def test_feature_dispatch_with_build_eval_invocation_should_pass(tmp_path: Path) -> None:
386
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE, SKILL_LOAD_LINE])
387
+ all_inputs = {"head": "main", "title": "feat: add a gate", "body": EVALUATED_BODY}
388
+ assert gate.decision_for(_dispatch_payload(tmp_path, all_inputs, transcript_path)) is None
389
+
390
+
391
+ def test_pull_request_dispatch_without_the_skill_loaded_should_be_denied(tmp_path: Path) -> None:
392
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
393
+ all_inputs = {"head": "main", "title": "fix: repair a gate", "body": EVALUATED_BODY}
394
+ _assert_denied_with(_dispatch_payload(tmp_path, all_inputs, transcript_path), DENY_REASON)
395
+
396
+
397
+ def test_dispatch_that_opens_no_pull_request_should_pass_without_the_skill(tmp_path: Path) -> None:
398
+ transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
399
+ assert gate.decision_for(_dispatch_payload(tmp_path, {"work_order": "wo-1"}, transcript_path)) is None
@@ -724,10 +724,7 @@ def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
724
724
  for each_group in hooks_configuration["hooks"]["PreToolUse"]
725
725
  for each_hook in each_group["hooks"]
726
726
  ]
727
- folded_script_relative_paths = (
728
- "advisory/refactor_guard.py",
729
- "advisory/migration_safety_advisor.py",
730
- )
727
+ folded_script_relative_paths = ("advisory/migration_safety_advisor.py",)
731
728
  for each_script_path in folded_script_relative_paths:
732
729
  matching_commands = [
733
730
  each_command
@@ -808,7 +805,6 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
808
805
 
809
806
 
810
807
  ALL_RETAINED_PATHS = {
811
- "advisory/refactor_guard.py",
812
808
  "advisory/migration_safety_advisor.py",
813
809
  }
814
810
  ALL_RETIRED_PATHS = {
@@ -1,4 +1,4 @@
1
- """Behavior tests for the proof-in-practice and existing-work checks on a new pull request."""
1
+ """Behavior tests for the proof-in-practice, existing-work and eval checks on a new pull request."""
2
2
 
3
3
  import subprocess
4
4
  import sys
@@ -11,6 +11,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
11
11
  from blocking import pull_request_proof as gate
12
12
  from hooks_constants.pull_request_proof_constants import (
13
13
  MISSING_BODY_REASON,
14
+ MISSING_EVAL_SECTION_REASON,
14
15
  MISSING_EXISTING_WORK_REASON,
15
16
  MISSING_PROOF_REASON,
16
17
  UNREADABLE_CHANGES_REASON,
@@ -210,3 +211,125 @@ def test_should_read_the_head_flag_of_a_shell_create(tmp_path: Path) -> None:
210
211
  reason = gate.missing_look_reason(payload)
211
212
  assert reason is not None
212
213
  assert "page.html" in reason
214
+
215
+
216
+ EVALUATED_BODY = SEARCHED_BODY + "\n## Eval\nTen labeled cases, exact-match grader. Ran `python eval.py`: 10/10.\n"
217
+ EVAL_WITHOUT_COMMAND_BODY = SEARCHED_BODY + "\n## Eval\nTen labeled cases, all pass.\n"
218
+
219
+
220
+ def _titled_mcp_create(title: object, body: object) -> dict[str, object]:
221
+ payload = _mcp_create(body)
222
+ payload["tool_input"]["title"] = title
223
+ return payload
224
+
225
+
226
+ def test_feature_mcp_create_without_an_eval_section_should_be_denied() -> None:
227
+ payload = _titled_mcp_create("feat: add a gate", SEARCHED_BODY)
228
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
229
+
230
+
231
+ def test_feature_mcp_create_with_an_eval_section_naming_a_command_should_pass() -> None:
232
+ assert gate.missing_eval_section_reason(_titled_mcp_create("feat: add a gate", EVALUATED_BODY)) is None
233
+
234
+
235
+ def test_feature_mcp_create_with_an_eval_heading_and_no_backtick_should_be_denied() -> None:
236
+ payload = _titled_mcp_create("feat: add a gate", EVAL_WITHOUT_COMMAND_BODY)
237
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
238
+
239
+
240
+ def test_eval_section_should_stop_at_a_heading_of_the_same_level() -> None:
241
+ body = SEARCHED_BODY + "\n## Evals\nCases listed.\n\n## Notes\nRan `python eval.py`.\n"
242
+ payload = _titled_mcp_create("feat: add a gate", body)
243
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
244
+
245
+
246
+ def test_fix_and_docs_titles_without_an_eval_section_should_pass() -> None:
247
+ for each_title in ("fix: repair a gate", "docs: describe a gate"):
248
+ payload = _titled_mcp_create(each_title, SEARCHED_BODY)
249
+ assert gate.missing_eval_section_reason(payload) is None, each_title
250
+ assert not gate.is_feature_pull_request(payload), each_title
251
+
252
+
253
+ def test_feature_titles_should_count_as_features() -> None:
254
+ for each_title in ("feat: x", "feat(hooks)!: x", "FEAT: x", "FEAT(x)!: y", " feat!: x"):
255
+ assert gate.is_feature_pull_request(_titled_mcp_create(each_title, SEARCHED_BODY)), each_title
256
+
257
+
258
+ def test_non_feature_titles_should_not_count_as_features() -> None:
259
+ for each_title in ("featured: x", "feature: x", "chore: feat: x", None, 7):
260
+ assert not gate.is_feature_pull_request(_titled_mcp_create(each_title, SEARCHED_BODY)), each_title
261
+
262
+
263
+ def test_gh_create_title_flag_should_be_read(tmp_path: Path) -> None:
264
+ (tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
265
+ payload = _shell('gh pr create --title "feat: x" --body-file b.md', tmp_path)
266
+ assert gate.is_feature_pull_request(payload)
267
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
268
+
269
+
270
+ def test_gh_create_title_assignment_should_be_read(tmp_path: Path) -> None:
271
+ (tmp_path / "b.md").write_text(EVALUATED_BODY, encoding="utf-8")
272
+ payload = _shell("gh pr create --title=feat:x --body-file b.md", tmp_path)
273
+ assert gate.is_feature_pull_request(payload)
274
+ assert gate.missing_eval_section_reason(payload) is None
275
+
276
+
277
+ def test_pull_request_script_create_title_should_be_read(tmp_path: Path) -> None:
278
+ (tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
279
+ payload = _shell(
280
+ 'python pull_request.py create --title "feat(x): y" --body-file b.md --repo o/r', tmp_path
281
+ )
282
+ assert gate.is_feature_pull_request(payload)
283
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
284
+
285
+
286
+ def test_gh_create_without_a_title_should_not_count_as_a_feature(tmp_path: Path) -> None:
287
+ (tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
288
+ payload = _shell("gh pr create --body-file b.md", tmp_path)
289
+ assert not gate.is_feature_pull_request(payload)
290
+ assert gate.missing_eval_section_reason(payload) is None
291
+
292
+
293
+ def test_feature_create_with_an_unreadable_body_should_leave_the_deny_to_the_proof_check() -> None:
294
+ payload = _shell('gh pr create --title "feat: x" --fill')
295
+ assert gate.missing_eval_section_reason(payload) is None
296
+ assert gate.missing_proof_reason(payload) == MISSING_BODY_REASON
297
+
298
+
299
+ def _dispatch(all_inputs: dict[str, object], method: str = "run_workflow") -> dict[str, object]:
300
+ return {
301
+ "tool_name": "mcp__github__actions_run_trigger",
302
+ "tool_input": {
303
+ "method": method,
304
+ "owner": "o",
305
+ "repo": "r",
306
+ "workflow_id": "open.yml",
307
+ "ref": "main",
308
+ "inputs": all_inputs,
309
+ },
310
+ }
311
+
312
+
313
+ def test_pull_request_dispatch_without_proof_should_be_denied() -> None:
314
+ payload = _dispatch({"head": "feature", "title": "fix: y", "body": UNPROVEN_BODY})
315
+ assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
316
+
317
+
318
+ def test_pull_request_dispatch_with_no_body_input_should_be_denied() -> None:
319
+ assert gate.missing_proof_reason(_dispatch({"head": "feature", "title": "fix: y"})) == MISSING_PROOF_REASON
320
+
321
+
322
+ def test_feature_dispatch_without_an_eval_section_should_be_denied() -> None:
323
+ payload = _dispatch({"head": "feature", "title": "feat: x", "body": SEARCHED_BODY})
324
+ assert gate.is_feature_pull_request(payload)
325
+ assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
326
+
327
+
328
+ def test_dispatch_that_names_no_head_or_runs_no_workflow_should_not_count_as_a_create() -> None:
329
+ all_payloads = [
330
+ _dispatch({"title": "feat: x", "body": UNPROVEN_BODY}),
331
+ _dispatch({"head": "feature", "title": "feat: x", "body": UNPROVEN_BODY}, "rerun_workflow_run"),
332
+ ]
333
+ for each_payload in all_payloads:
334
+ assert gate.missing_proof_reason(each_payload) is None
335
+ assert not gate.is_feature_pull_request(each_payload)
@@ -29,4 +29,5 @@ Each family page starts with a title and an agent-facing behavior summary. Its f
29
29
  - [Session context](./session-context.md) covers skill reminders, startup guidance, and the auto mode denial quick fix.
30
30
  - [Spawn routing](./spawn-routing.md) covers spawn readiness, pacing, and model selection.
31
31
  - [Observability](./observability.md) covers instruction loads, edited files, and investigation resets.
32
+ - [Read gates](./read-gates.md) covers the image size cap on Read.
32
33
  - [Lifecycle cleanup](./lifecycle-cleanup.md) covers nested checkouts, worktree setup, and session cleanup.
@@ -24,7 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
24
24
  - `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
25
25
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
26
26
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
27
- - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
27
+ - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
28
28
  - `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
29
29
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
30
30
 
@@ -0,0 +1,20 @@
1
+ # Read gates
2
+
3
+ This family stops a Read before it puts too much into the context. An agent that opens a full-size screenshot spends up to 4,784 visual tokens on one image, so the gate sends it to a capped copy first.
4
+
5
+ ## Checks
6
+
7
+ - `blocking/image_read_size_gate.py` denies a Read of a PNG, JPEG or GIF file whose long edge is over 512 pixels and names `scripts/agent_image_copy.py`, which writes a `<name>.agent.png` copy at 512 pixels or less with the aspect ratio kept.
8
+
9
+ ## When it fires
10
+
11
+ - `blocking/image_read_size_gate.py` runs on `PreToolUse`, matcher `Read`, timeout `10` seconds in `hooks.json`. It reads only the file header, so a text file, a missing file, a WebP file and an unknown format pass.
12
+
13
+ ## Proving it
14
+
15
+ - **Full-size screenshot.** Input is a Read of a 1440x2560 PNG. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_image_read_size_gate.py -q`. The adjacent test observes a denial that names `agent_image_copy.py`; a Read of a 288x512 copy passes.
16
+
17
+ ## Gotchas
18
+
19
+ - The copy is written after the source, so a gate that wants a screenshot read after a page changed still counts the capped copy.
20
+ - A person gets the full-size file. The cap applies to what an agent reads.