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
@@ -3,7 +3,8 @@ name: second-account-workers
3
3
  description: >-
4
4
  Run local headless Claude workers through the account picker.
5
5
  Triggers: spawn a worker on the second account or another extra account, offload a worker,
6
- use a named Claude profile, save main account usage.
6
+ use a named Claude profile, save main account usage, run a local orchestrator that spawns
7
+ and watches Claude workers.
7
8
  ---
8
9
 
9
10
  # Extra account workers
@@ -22,8 +23,9 @@ JSON list of profile names, with the first choice first. Names use letters,
22
23
  digits, hyphens, and underscores. The worker uses the existing second profile
23
24
  when the file is absent. When the file exists, its list sets the full order.
24
25
  Keep `main` and `wait` out of the list because they name picker decisions.
25
- The picker ranks main and every extra profile by room left and picks the one
26
- with the most.
26
+ The picker keeps each account that has room and gives the job to the first one
27
+ listed in `claude-account-order.json` in the main Claude home. Unlisted accounts
28
+ follow, by most room left.
27
29
  For direct picker calls, `--second-config-dir` sets the first extra profile and
28
30
  each `--extra-config-dir` adds another in the order given.
29
31
  Picker JSON prints `config_dir` for the choice and meters under `main`, `second`,
@@ -47,6 +49,13 @@ worker_exit=$?
47
49
  cat "<report>"
48
50
  ```
49
51
 
52
+ Add `--live-log "<events.jsonl>"` to watch the worker while it runs. The worker
53
+ then writes one JSON event per line into that file as it happens. Every event
54
+ carries the `session_id`, from the first startup hook line on. Each `assistant` line names the tool
55
+ calls and text, and the last line is the `result` event. Read new lines with
56
+ `tail -n 20 "<events.jsonl>"` and skip screenshots unless a check needs to see
57
+ the screen.
58
+
50
59
  Read the JSON report and worker log after the process ends. The JSON fields are
51
60
  `account`, `reason`, `exit_code`, `duration_seconds`, `result`, and `is_error`.
52
61
  The runner writes one summary line with the selected account, exit code, and
@@ -83,3 +92,12 @@ Blocked: <open issue, or none>
83
92
 
84
93
  Keep the owned file list closed. Name the worktree path with `--cwd` and use a
85
94
  separate brief and report path for each worker.
95
+
96
+ ## Run a local orchestrator
97
+
98
+ To save usage in a long session, start one local orchestrator worker and let it
99
+ spawn the other workers. Give it its own brief that names each worker brief,
100
+ worktree, report path and live log. It launches each worker with the command
101
+ above in the background, reads the live logs to follow progress, and reads each
102
+ report at the end. Each spawn goes through the picker, so every worker lands on
103
+ the account with the most weekly room at its start.
@@ -0,0 +1,40 @@
1
+ Back to the [rule entry](../../rules/features-start-with-an-eval.md).
2
+
3
+ # Features start with an eval
4
+
5
+ **When this applies:** A user asks for a new feature or a change in behavior, in any project.
6
+
7
+ ## What counts
8
+
9
+ A feature is behavior a user or a caller can see that did not exist before, or that now works differently: a command, a flag, a page, an endpoint, a hook, a rule, a skill, a report, or an integration. A bug fix that restores intended behavior, a rename, a refactor, a docs edit, a question, a test run, and speed work that leaves the output the same are not features.
10
+
11
+ An eval is three things for one feature: a set of input cases with their expected results, a runner that drives the feature through the entry point its users call, and a grader that scores each output. A test suite counts when it does all three for that feature.
12
+
13
+ ## Rule
14
+
15
+ 1. Make the Skill tool call with skill `claude-api` and args `build-eval` your first action on the ask. A user who types `/claude-api build-eval` has made the same call.
16
+ 2. Run the existing-work search from [`explore-thoroughly.md`](../../rules/explore-thoroughly.md) next. The guide's own first step also looks for cases, graders, and runners that exist.
17
+ 3. Follow the guide the skill loads. It asks the user to approve the inputs and the grading method, and to approve the cost before the first paid run. Ask through the session's question tool. Where the host says to keep working while a question waits, continue on the option you recommended and change course if the answer differs.
18
+ 4. Run the eval before the change to get a baseline, build the feature, and run it again.
19
+ 5. Give the pull request an "Eval" section. Name the cases and the grader, give the command that runs the eval in backticks, and quote the baseline and the result.
20
+
21
+ ## An existing feature with no eval
22
+
23
+ When your work touches an existing feature, look for its eval. When it has none, start a separate session that builds it, and keep doing your own work while that session runs. Your own feature still starts with its own eval.
24
+
25
+ - In a Claude project thread, call `send_message` with the coordinator's session id from `get_channel_session_id`, and ask it to start a thread for the eval. A thread session has no `start_thread_session`, so the coordinator starts that thread.
26
+ - In a project's coordinator session, call `start_thread_session` with the brief.
27
+ - In a command-line session, start a headless session through the account broker: `python ~/.claude/scripts/account_broker.py run --product claude --report <report.json> -- claude -p "<brief>" --model <model> --effort <level>`.
28
+
29
+ The brief names the feature, its entry point, and the first step, the `claude-api` skill with args `build-eval`. An Agent-tool subagent is not a separate session. It shares your context window and ends with your turn.
30
+
31
+ You own that session's result. Check its pull request, and link it from your own pull request's "Eval" section.
32
+
33
+ ## Enforcement
34
+
35
+ `hooks/blocking/pull_request_proof.py` reads each new pull request before it opens, through `pr_lifecycle_skill_gate.py`. A new pull request is a `gh pr create` command, a GitHub MCP create call, or a GitHub MCP `run_workflow` dispatch whose inputs name a `head` branch and a `title`, because that workflow opens the pull request. A pull request whose title starts with `feat` (`feat:`, `feat(scope):`, `feat!:`) is denied when:
36
+
37
+ - its body has no "Eval" heading, or the text under that heading names no command in backticks;
38
+ - the session's transcript is readable and shows no `claude-api` Skill call with args `build-eval`, and no `/claude-api build-eval` command.
39
+
40
+ The gate checks that the section and the call are there. Whether the eval measures the feature stays a judgment for the author and the reviewer. The order of the first action and the separate session for an existing feature are not checked by a hook, so the agent applies them.
@@ -19,16 +19,20 @@ from hooks_constants.followup_pr_dedupe_constants import (
19
19
  ALL_BODY_FILE_OPTIONS,
20
20
  ALL_BODY_OPTIONS,
21
21
  ALL_REPOSITORY_OPTIONS,
22
+ ALL_TITLE_OPTIONS,
22
23
  CREATE_PULL_REQUEST_TOOL_SUFFIX,
23
24
  DUPLICATE_FOLLOWUP_REASON_TEMPLATE,
24
25
  FOLLOWUP_PARENT_PATTERN,
25
26
  ALL_GH_CREATE_WORDS,
26
27
  ALL_GIT_REMOTE_COMMAND_WORDS,
28
+ ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS,
27
29
  GITHUB_API_ROOT,
28
30
  GITHUB_REMOTE_PATTERN,
29
31
  OPEN_PULL_REQUESTS_PATH_TEMPLATE,
30
32
  READ_TIMEOUT_SECONDS,
33
+ RUN_WORKFLOW_METHOD,
31
34
  STANDARD_INPUT_PATH,
35
+ WORKFLOW_DISPATCH_TOOL_SUFFIX,
32
36
  )
33
37
  from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
34
38
  from hooks_constants.pytest_invocation import unquoted_token
@@ -99,6 +103,18 @@ def body_from_arguments(all_arguments: list[str], working_directory: str) -> str
99
103
  return None
100
104
 
101
105
 
106
+ def title_from_arguments(all_arguments: list[str]) -> str | None:
107
+ """Return the title a gh-style argument list passes, or None when it passes none.
108
+
109
+ ::
110
+
111
+ ["--title", "feat: x"] -> "feat: x"
112
+ ["-t=fix: y"] -> "fix: y"
113
+ ["--fill"] -> None
114
+ """
115
+ return _option_value(all_arguments, ALL_TITLE_OPTIONS)
116
+
117
+
102
118
  def _origin_repository(working_directory: str) -> tuple[str, str] | None:
103
119
  try:
104
120
  completed = subprocess.run(
@@ -146,19 +162,53 @@ def _shell_draft(command: str, working_directory: str) -> PullRequestDraft | Non
146
162
  return next((each_draft for each_draft in all_drafts if each_draft is not None), None)
147
163
 
148
164
 
149
- def pull_request_draft(all_payload_fields: Mapping[str, object]) -> PullRequestDraft | None:
150
- """Return the pull request a PreToolUse payload is about to open, or None."""
165
+ def _dispatch_fields(all_tool_input_fields: Mapping[str, object]) -> dict[str, object] | None:
166
+ all_inputs = all_tool_input_fields.get("inputs")
167
+ if all_tool_input_fields.get("method") != RUN_WORKFLOW_METHOD or not isinstance(all_inputs, dict):
168
+ return None
169
+ if not all(isinstance(all_inputs.get(each_key), str) for each_key in ALL_PULL_REQUEST_DISPATCH_INPUT_KEYS):
170
+ return None
171
+ return {**all_inputs, "owner": all_tool_input_fields.get("owner"), "repo": all_tool_input_fields.get("repo")}
172
+
173
+
174
+ def create_tool_fields(all_payload_fields: Mapping[str, object]) -> Mapping[str, object] | None:
175
+ """Return the fields a GitHub MCP call that opens a pull request carries, or None.
176
+
177
+ ::
178
+
179
+ create_pull_request {owner, repo, title, body} -> those fields
180
+ actions_run_trigger run_workflow, head and title in -> the inputs, owner and repo
181
+ actions_run_trigger whose inputs name no head -> None
182
+
183
+ A dispatch whose inputs name a head branch and a title runs a workflow
184
+ that opens the pull request, so it counts as a create call.
185
+
186
+ Args:
187
+ all_payload_fields: The parsed PreToolUse input.
188
+
189
+ Returns:
190
+ The owner, repo, title, body and head keys the call names, or None.
191
+ """
151
192
  tool_name = all_payload_fields.get("tool_name")
152
193
  tool_input = all_payload_fields.get("tool_input")
153
194
  if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
154
195
  return None
155
196
  if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
156
- owner, repo, body = (tool_input.get(each_key) for each_key in ("owner", "repo", "body"))
197
+ return tool_input
198
+ return _dispatch_fields(tool_input) if tool_name.endswith(WORKFLOW_DISPATCH_TOOL_SUFFIX) else None
199
+
200
+
201
+ def pull_request_draft(all_payload_fields: Mapping[str, object]) -> PullRequestDraft | None:
202
+ """Return the pull request a PreToolUse payload is about to open, or None."""
203
+ create_fields = create_tool_fields(all_payload_fields)
204
+ if create_fields is not None:
205
+ owner, repo, body = (create_fields.get(each_key) for each_key in ("owner", "repo", "body"))
157
206
  if isinstance(owner, str) and isinstance(repo, str) and isinstance(body, str):
158
207
  return PullRequestDraft(owner, repo, body)
159
208
  return None
160
- command = tool_input.get("command")
161
- if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
209
+ tool_input = all_payload_fields.get("tool_input")
210
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
211
+ if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
162
212
  return None
163
213
  working_directory = all_payload_fields.get("cwd")
164
214
  return _shell_draft(command, working_directory if isinstance(working_directory, str) else "")
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: refuse a Read of an image larger than the agent cap.
3
+
4
+ A Read of an image puts the whole picture in the context. A phone screenshot
5
+ at 1440x2560 costs about 4,784 tokens, the most one image can cost. The hook
6
+ reads the width and height from the file header and refuses the Read when the
7
+ long edge passes the cap, naming the command that writes a capped copy::
8
+
9
+ Read shot.png (1440x2560) -> refused, names agent_image_copy.py
10
+ Read shot.agent.png (288x512) -> allowed
11
+ Read notes.md -> allowed
12
+ Read broken.png (no header) -> allowed
13
+
14
+ A missing file, a WebP or other unknown format and an unreadable header all pass, so the
15
+ hook never fails closed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import struct
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
26
+ if hooks_root_directory not in sys.path:
27
+ sys.path.insert(0, hooks_root_directory)
28
+
29
+ from hooks_constants.hook_specific_output_keys import (
30
+ HOOK_EVENT_NAME_KEY,
31
+ HOOK_SPECIFIC_OUTPUT_KEY,
32
+ PERMISSION_DECISION_KEY,
33
+ )
34
+ from hooks_constants.image_read_size_gate_constants import (
35
+ AGENT_COPY_SCRIPT_PATH,
36
+ ALL_JPEG_FRAME_MARKERS,
37
+ ALL_JPEG_STANDALONE_MARKERS,
38
+ DENY_DECISION,
39
+ FILE_PATH_INPUT_KEY,
40
+ ALL_GIF_SIGNATURES,
41
+ GIF_SIZE_END,
42
+ GIF_SIZE_START,
43
+ HEADER_BYTE_COUNT,
44
+ JPEG_FRAME_SIZE_END,
45
+ JPEG_FRAME_SIZE_START,
46
+ JPEG_MARKER_LENGTH,
47
+ JPEG_MARKER_PREFIX,
48
+ JPEG_SIGNATURE,
49
+ MAXIMUM_LONG_EDGE_PIXELS,
50
+ OVERSIZED_IMAGE_REASON_TEMPLATE,
51
+ PERMISSION_DECISION_REASON_KEY,
52
+ PNG_SIGNATURE,
53
+ PNG_SIZE_END,
54
+ PNG_SIZE_START,
55
+ PRE_TOOL_USE_EVENT_NAME,
56
+ READ_TOOL_NAME,
57
+ TOOL_INPUT_KEY,
58
+ TOOL_NAME_KEY,
59
+ )
60
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
61
+
62
+
63
+ def _jpeg_size(header: bytes) -> tuple[int, int] | None:
64
+ offset = len(JPEG_SIGNATURE)
65
+ while offset + JPEG_FRAME_SIZE_END <= len(header):
66
+ if header[offset] != JPEG_MARKER_PREFIX:
67
+ return None
68
+ marker = header[offset + 1]
69
+ if marker == JPEG_MARKER_PREFIX:
70
+ offset += 1
71
+ continue
72
+ if marker in ALL_JPEG_STANDALONE_MARKERS:
73
+ offset += JPEG_MARKER_LENGTH
74
+ continue
75
+ if marker in ALL_JPEG_FRAME_MARKERS:
76
+ height, width = struct.unpack(
77
+ ">HH", header[offset + JPEG_FRAME_SIZE_START : offset + JPEG_FRAME_SIZE_END]
78
+ )
79
+ return width, height
80
+ segment_length_end = offset + JPEG_MARKER_LENGTH + JPEG_MARKER_LENGTH
81
+ offset += JPEG_MARKER_LENGTH + struct.unpack(">H", header[offset + JPEG_MARKER_LENGTH : segment_length_end])[0]
82
+ return None
83
+
84
+
85
+ def image_size(header: bytes) -> tuple[int, int] | None:
86
+ """Return (width, height) read from the first bytes of a PNG, JPEG or GIF file.
87
+
88
+ ::
89
+
90
+ PNG header of a 1440x2560 file -> (1440, 2560)
91
+ b"plain text" -> None
92
+
93
+ Args:
94
+ header: The first bytes of the file.
95
+ """
96
+ if header.startswith(PNG_SIGNATURE) and len(header) >= PNG_SIZE_END:
97
+ width, height = struct.unpack(">II", header[PNG_SIZE_START:PNG_SIZE_END])
98
+ return width, height
99
+ if header.startswith(ALL_GIF_SIGNATURES) and len(header) >= GIF_SIZE_END:
100
+ width, height = struct.unpack("<HH", header[GIF_SIZE_START:GIF_SIZE_END])
101
+ return width, height
102
+ if header.startswith(JPEG_SIGNATURE):
103
+ return _jpeg_size(header)
104
+ return None
105
+
106
+
107
+ def _read_header(file_path: str) -> bytes:
108
+ try:
109
+ with open(file_path, "rb") as image_file:
110
+ return image_file.read(HEADER_BYTE_COUNT)
111
+ except OSError:
112
+ return b""
113
+
114
+
115
+ def oversized_image_reason(all_hook_fields: dict[str, object]) -> str | None:
116
+ """Return the refusal for a Read of an image over the cap, or None to allow it.
117
+
118
+ Args:
119
+ all_hook_fields: The parsed PreToolUse payload.
120
+ """
121
+ tool_input = all_hook_fields.get(TOOL_INPUT_KEY)
122
+ if all_hook_fields.get(TOOL_NAME_KEY) != READ_TOOL_NAME or not isinstance(tool_input, dict):
123
+ return None
124
+ file_path = tool_input.get(FILE_PATH_INPUT_KEY)
125
+ if not isinstance(file_path, str):
126
+ return None
127
+ dimensions = image_size(_read_header(file_path))
128
+ if dimensions is None or max(dimensions) <= MAXIMUM_LONG_EDGE_PIXELS:
129
+ return None
130
+ width, height = dimensions
131
+ return OVERSIZED_IMAGE_REASON_TEMPLATE.format(
132
+ width=width,
133
+ height=height,
134
+ maximum_edge=MAXIMUM_LONG_EDGE_PIXELS,
135
+ script_path=AGENT_COPY_SCRIPT_PATH,
136
+ image_path=file_path,
137
+ )
138
+
139
+
140
+ def main() -> int:
141
+ """Read the PreToolUse payload and print a deny decision for an oversized image.
142
+
143
+ Returns:
144
+ 0 in every case; the decision travels in the JSON output.
145
+ """
146
+ hook_payload = read_hook_input_dictionary_from_stdin()
147
+ if hook_payload is None:
148
+ return 0
149
+ refusal_reason = oversized_image_reason(hook_payload)
150
+ if refusal_reason is not None:
151
+ sys.stdout.write(
152
+ json.dumps(
153
+ {
154
+ HOOK_SPECIFIC_OUTPUT_KEY: {
155
+ HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
156
+ PERMISSION_DECISION_KEY: DENY_DECISION,
157
+ PERMISSION_DECISION_REASON_KEY: refusal_reason,
158
+ }
159
+ }
160
+ )
161
+ )
162
+ sys.stdout.flush()
163
+ return 0
164
+
165
+
166
+ if __name__ == "__main__":
167
+ sys.exit(main())
@@ -6,6 +6,7 @@ import json
6
6
  import re
7
7
  import shlex
8
8
  import sys
9
+ from collections.abc import Callable, Iterable
9
10
  from pathlib import Path
10
11
 
11
12
  hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -15,6 +16,7 @@ if hooks_directory not in sys.path:
15
16
  from hooks_constants.pr_lifecycle_skill_gate_constants import (
16
17
  AGENT_ID_FIELD,
17
18
  AGENT_ID_PATTERN,
19
+ ALL_BUILD_EVAL_SKILL_NAMES,
18
20
  ALL_API_ACTION_NAMES,
19
21
  ALL_COMMAND_PREFIX_WORDS,
20
22
  ALL_GITHUB_MCP_TOOL_SUFFIXES,
@@ -23,6 +25,8 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
23
25
  ALL_SHELL_WRAPPER_EXECUTABLES,
24
26
  ALL_SLASH_COMMAND_MARKERS,
25
27
  ALL_TRANSCRIPT_PATH_FIELDS,
28
+ BUILD_EVAL_ARGUMENT_WORD,
29
+ BUILD_EVAL_COMMAND_MARKER,
26
30
  COMMAND_SEPARATORS,
27
31
  COMMAND_WHITESPACE,
28
32
  DENY_DECISION,
@@ -52,13 +56,19 @@ from hooks_constants.hook_specific_output_keys import (
52
56
  )
53
57
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
54
58
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
55
- from blocking.followup_pr_dedupe import duplicate_followup_reason
59
+ from blocking.followup_pr_dedupe import create_tool_fields, duplicate_followup_reason
56
60
  from blocking.pull_request_proof import (
61
+ is_feature_pull_request,
62
+ missing_eval_section_reason,
57
63
  missing_existing_work_reason,
58
64
  missing_look_reason,
59
65
  missing_proof_reason,
60
66
  )
61
- from transcript_skill_scan import is_skill_loaded_after_last_compaction
67
+ from hooks_constants.pull_request_proof_constants import MISSING_BUILD_EVAL_REASON
68
+ from transcript_skill_scan import (
69
+ invokes_skill_with_argument,
70
+ is_skill_loaded_after_last_compaction,
71
+ )
62
72
 
63
73
 
64
74
  def _command_segments(command: str) -> list[list[str]]:
@@ -169,7 +179,7 @@ def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
169
179
  tool_name = all_payload_fields.get("tool_name")
170
180
  if not isinstance(tool_name, str):
171
181
  return False
172
- if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES):
182
+ if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES) or create_tool_fields(all_payload_fields) is not None:
173
183
  return True
174
184
  if tool_name not in SHELL_TOOL_NAMES:
175
185
  return False
@@ -177,16 +187,29 @@ def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
177
187
  return isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str) and _matches_shell_command(tool_input["command"])
178
188
 
179
189
 
180
- def _transcript_load_status(path: str) -> bool | None:
190
+ def _scanned_transcript(path: str, scan: Callable[[Iterable[str]], bool]) -> bool | None:
181
191
  try:
182
192
  with open(path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
183
- return is_skill_loaded_after_last_compaction(
184
- transcript, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
185
- )
193
+ return scan(transcript)
186
194
  except OSError:
187
195
  return None
188
196
 
189
197
 
198
+ def _shows_skill_load(all_transcript_lines: Iterable[str]) -> bool:
199
+ return is_skill_loaded_after_last_compaction(
200
+ all_transcript_lines, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
201
+ )
202
+
203
+
204
+ def _shows_build_eval(all_transcript_lines: Iterable[str]) -> bool:
205
+ return invokes_skill_with_argument(
206
+ all_transcript_lines,
207
+ ALL_BUILD_EVAL_SKILL_NAMES,
208
+ BUILD_EVAL_ARGUMENT_WORD,
209
+ BUILD_EVAL_COMMAND_MARKER,
210
+ )
211
+
212
+
190
213
  def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | None:
191
214
  session_path = all_payload_fields.get(SESSION_TRANSCRIPT_PATH_FIELD)
192
215
  agent_id = all_payload_fields.get(AGENT_ID_FIELD)
@@ -203,7 +226,17 @@ def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | No
203
226
 
204
227
 
205
228
  def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
206
- """Return a deny for an unloaded skill, a new pull request with no proof or existing-work section, or a second follow-up.
229
+ """Return a deny for the first rule a pull request action breaks, else None.
230
+
231
+ ::
232
+
233
+ pr-lifecycle skill not loaded -> DENY_REASON
234
+ new pull request with no proof section -> MISSING_PROOF_REASON
235
+ new pull request with no existing-work section -> MISSING_EXISTING_WORK_REASON
236
+ feature pull request with no eval section -> MISSING_EVAL_SECTION_REASON
237
+ changed page whose proof shows no picture -> MISSING_LOOK_REASON
238
+ feature pull request, no /claude-api build-eval -> MISSING_BUILD_EVAL_REASON
239
+ second follow-up pull request for one parent -> DUPLICATE_FOLLOWUP_REASON_TEMPLATE
207
240
 
208
241
  Args:
209
242
  all_payload_fields: The parsed PreToolUse input.
@@ -218,23 +251,41 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
218
251
  search_reason = missing_existing_work_reason(all_payload_fields)
219
252
  if search_reason is not None:
220
253
  return _deny(search_reason)
254
+ eval_reason = missing_eval_section_reason(all_payload_fields)
255
+ if eval_reason is not None:
256
+ return _deny(eval_reason)
221
257
  look_reason = missing_look_reason(all_payload_fields)
222
258
  if look_reason is not None:
223
259
  return _deny(look_reason)
260
+ if is_feature_pull_request(all_payload_fields) and _is_build_eval_missing(all_payload_fields):
261
+ return _deny(MISSING_BUILD_EVAL_REASON)
224
262
  duplicate_reason = duplicate_followup_reason(all_payload_fields)
225
263
  return None if duplicate_reason is None else _deny(duplicate_reason)
226
264
 
227
265
 
228
- def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
266
+ def _transcript_paths(all_payload_fields: dict[str, object]) -> list[str]:
229
267
  paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
230
268
  paths.append(_subagent_transcript_path(all_payload_fields))
231
- readable_paths = list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
269
+ return list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
270
+
271
+
272
+ def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
273
+ readable_paths = _transcript_paths(all_payload_fields)
232
274
  if not readable_paths:
233
275
  return False
234
- statuses = [_transcript_load_status(path) for path in readable_paths]
276
+ statuses = [_scanned_transcript(path, _shows_skill_load) for path in readable_paths]
235
277
  return not any(status is None or status for status in statuses)
236
278
 
237
279
 
280
+ def _is_build_eval_missing(all_payload_fields: dict[str, object]) -> bool:
281
+ all_statuses = [
282
+ _scanned_transcript(each_path, _shows_build_eval)
283
+ for each_path in _transcript_paths(all_payload_fields)
284
+ ]
285
+ all_read_statuses = [each_status for each_status in all_statuses if each_status is not None]
286
+ return bool(all_read_statuses) and not any(all_read_statuses)
287
+
288
+
238
289
  def _deny(reason: str) -> dict[str, object]:
239
290
  return {
240
291
  HOOK_SPECIFIC_OUTPUT_KEY: {