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.
- package/.agents/skills/correction/SKILL.md +28 -6
- package/.agents/skills/correction/references/filing.md +33 -0
- package/.agents/skills/correction/scripts/correction_filing.py +327 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
- package/.agents/skills/correction/scripts/pytest.ini +4 -0
- package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
- package/.agents/skills/second-account-workers/SKILL.md +21 -3
- package/docs/rule-guides/features-start-with-an-eval.md +40 -0
- package/hooks/blocking/followup_pr_dedupe.py +55 -5
- package/hooks/blocking/image_read_size_gate.py +167 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
- package/hooks/blocking/pull_request_proof.py +86 -20
- package/hooks/blocking/test_artifact_look_gate.py +1 -0
- package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
- package/hooks/blocking/test_image_read_size_gate.py +79 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
- package/hooks/blocking/test_pull_request_proof.py +124 -1
- package/hooks/features/README.md +1 -0
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/features/read-gates.md +20 -0
- package/hooks/features/write-edit-blocking.md +3 -5
- package/hooks/hooks.json +11 -1
- package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
- package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
- package/hooks/test_transcript_skill_scan.py +75 -1
- package/hooks/transcript_skill_scan.py +87 -15
- package/package.json +1 -1
- package/rules/anti-corollary-tests.md +2 -2
- package/rules/archiving-agent-config.md +2 -2
- package/rules/asd-ste100-language.md +2 -2
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/cleanup-temp-files.md +3 -3
- package/rules/code-standards.md +1 -1
- package/rules/correction-lens.md +3 -3
- package/rules/destructive-commands.md +3 -3
- package/rules/doc-inventory-integrity.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/explore-thoroughly.md +1 -1
- package/rules/failure-blast-radius.md +1 -1
- package/rules/falsify-before-green.md +2 -2
- package/rules/features-start-with-an-eval.md +9 -0
- package/rules/filesystem-search.md +3 -3
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/long-horizon-autonomy.md +2 -2
- package/rules/memory-stores-durable-facts.md +2 -2
- package/rules/no-contrast-framing.md +1 -1
- package/rules/no-cross-skill-duplicate-helpers.md +1 -1
- package/rules/orphan-css-class.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +1 -1
- package/rules/prefer-existing-tools.md +2 -2
- package/rules/proof-before-pull-request.md +1 -1
- package/rules/request-scope.md +1 -1
- package/rules/research-mode.md +2 -2
- package/rules/retired-hook-prose.md +1 -1
- package/rules/session-title.md +1 -1
- package/rules/shell-invocation.md +2 -2
- package/rules/testing.md +1 -1
- package/rules/verify-before-asking.md +3 -3
- package/rules/verify-runtime-state.md +2 -2
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/account_broker.py +10 -3
- package/scripts/account_broker_support.py +7 -3
- package/scripts/agent_image_copy.py +155 -0
- package/scripts/claude_account_worker.py +24 -5
- package/scripts/claude_account_worker_process.py +5 -0
- package/scripts/claude_account_worker_report.py +16 -1
- package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_account_broker_support.py +24 -0
- package/scripts/test_agent_image_copy.py +76 -0
- package/scripts/test_claude_account_worker.py +47 -0
- package/scripts/test_claude_account_worker_process.py +20 -0
- package/scripts/test_claude_account_worker_report.py +15 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/hooks/advisory/conftest.py +0 -10
- package/hooks/advisory/refactor_guard.py +0 -385
- package/hooks/advisory/refactor_guard_test_support.py +0 -46
- package/hooks/advisory/test_refactor_guard.py +0 -148
- package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
- package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
- package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
- 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
|
|
26
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = [
|
|
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: {
|