claude-dev-env 8.69.1 → 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.
@@ -1,16 +1,34 @@
1
1
  ---
2
2
  name: correction
3
- description: Turn a correction the user just made into a short handoff brief they paste into a corrections intake, or land the fix in this session with the fix playbook. Use when the user types /correction followed by what the agent got wrong, or /correction fix to land the fix here.
3
+ description: File a correction the user made to an agent as one labeled GitHub issue, list the open corrections, write a handoff brief, or land the fix in this session with the fix playbook. Use when the user types /correction, /correction fix, says "file this as a correction", or asks to show the open corrections.
4
4
  argument-hint: "[fix] <what the agent got wrong, in the user's words>"
5
5
  ---
6
6
 
7
7
  # /correction
8
8
 
9
- The user corrected an agent and wants the fix to stick. Write one brief that records the correction. By default the user pastes it into the place that turns corrections into lasting fixes. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself.
9
+ The user corrected an agent and wants the fix to stick. A labeled issue is the shared store a corrections project watches. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself: write the brief, then follow the fix playbook.
10
10
 
11
- `$ARGUMENTS` is the user's statement of the correction, after a leading `fix` when one is present. Fill the other fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. When the session does not settle a field, write `unknown` there.
11
+ ## File it
12
12
 
13
- Print the brief as one fenced `text` block:
13
+ Use this for `/correction <text>` and "file this as a correction: <text>". Run:
14
+
15
+ ```bash
16
+ python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" file <<'CORRECTION'
17
+ <the correction, word for word>
18
+ CORRECTION
19
+ ```
20
+
21
+ Pass the user's words exactly. The quoted heredoc keeps the shell from expanding `$`, backticks, and quotes in them. Print the one line the command prints, `Filed: <url>` or `Already filed: <url>`, and nothing else.
22
+
23
+ When it prints `No correction filing config`, write the brief below instead and tell the user the config file it names.
24
+
25
+ ## List them
26
+
27
+ For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines.
28
+
29
+ ## Brief
30
+
31
+ Print one fenced `text` block. Fill the fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. Write `unknown` for a field the session does not settle.
14
32
 
15
33
  ```text
16
34
  Correction: <$ARGUMENTS word for word, minus a leading fix>
@@ -28,12 +46,16 @@ Keep each field to one or two lines. Leave out the layer and the fix design; the
28
46
 
29
47
  | Mode | What follows the brief |
30
48
  |---|---|
31
- | Hand off, the default | Nothing. The brief is the whole reply. |
49
+ | File, the default | The filed line, or the brief when no config exists. |
32
50
  | `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
33
51
 
34
52
  ## Layout
35
53
 
36
54
  | File | What it holds |
37
55
  |---|---|
38
- | `SKILL.md` | The brief and the playbook index |
56
+ | `SKILL.md` | Filing, the brief, and the playbook index |
39
57
  | `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
58
+ | `scripts/correction_filing.py` | Files one correction as a labeled issue, deduplicated, or lists the open ones |
59
+ | `scripts/correction_filing_constants/config/constants.py` | Config keys, issue shape, and redaction patterns |
60
+ | `scripts/test_correction_filing.py` | Behavior tests with a stand-in `gh` |
61
+ | `references/filing.md` | Config file, issue shape, dedupe, and commands |
@@ -0,0 +1,33 @@
1
+ # Correction filing
2
+
3
+ `scripts/correction_filing.py` is the one filing path. The skill calls it.
4
+
5
+ ## Config
6
+
7
+ The script reads `~/.claude/correction-capture.json`, or the file `CLAUDE_CORRECTION_CAPTURE_PATH` names:
8
+
9
+ ```json
10
+ {"repository": "owner/name", "label": "correction"}
11
+ ```
12
+
13
+ Keep this file private. Without it the script files nothing and exits 1.
14
+
15
+ ## Issue shape
16
+
17
+ - Title: `Correction: ` and the first 72 characters of the text.
18
+ - Body: the source (`typed`, `flag`, `reaction`, or `hook`), the text as a quote, and a hidden dedupe marker.
19
+ - The script masks GitHub, OpenAI, Slack, and AWS tokens and email addresses before filing.
20
+ - The script applies the label itself.
21
+
22
+ ## Dedupe
23
+
24
+ The key is a hash of `--dedupe-key` when given, else of the whitespace-collapsed text. A caller with a message id passes it there. Before filing, the script checks `correction-capture-filed.json` beside the config file for this repository and key, then every labeled issue, open and closed, and prints `Already filed: <url>` on a key match. The local file covers the minute after filing, when the labeled-issue listing can miss a new issue.
25
+
26
+ ## Commands
27
+
28
+ Without `--text`, `file` reads the correction from standard input.
29
+
30
+ ```bash
31
+ python correction_filing.py file [--text "<text>"] [--source flag] [--dedupe-key <id>]
32
+ python correction_filing.py list
33
+ ```
@@ -0,0 +1,327 @@
1
+ """File a user's correction as one labeled GitHub issue, or list the open ones.
2
+
3
+ The repository and label come from a private config file, so nothing
4
+ identifying lives in this package::
5
+
6
+ ~/.claude/correction-capture.json
7
+ {"repository": "owner/name", "label": "correction"}
8
+
9
+ python correction_filing.py file --text "use shorter replies"
10
+ Filed: https://github.com/owner/name/issues/12
11
+ python correction_filing.py file --text "use shorter replies"
12
+ Already filed: https://github.com/owner/name/issues/12
13
+ python correction_filing.py file <<'CORRECTION'
14
+ use shorter replies
15
+ CORRECTION
16
+ Already filed: https://github.com/owner/name/issues/12
17
+ python correction_filing.py list
18
+ #12 Correction: use shorter replies https://github.com/...
19
+
20
+ The issue quotes only the correction text, with tokens and email addresses
21
+ masked. A hidden dedupe marker in the body, and a local ledger of filed keys
22
+ beside the config file, keep a replayed correction from filing twice. CLAUDE_CORRECTION_CAPTURE_PATH names another config file.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from urllib.parse import quote
37
+
38
+ from correction_filing_constants.config.constants import (
39
+ ALL_REDACTION_PATTERNS,
40
+ ALL_SOURCES,
41
+ BODY_FIELD_TEMPLATE,
42
+ CLAUDE_DIRECTORY_NAME,
43
+ CONFIG_ENCODING,
44
+ CONFIG_FILE_NAME,
45
+ CONFIG_LABEL_KEY,
46
+ CONFIG_PATH_ENV_VAR,
47
+ CONFIG_REPOSITORY_KEY,
48
+ CREATED_URL_JQ_FILTER,
49
+ DEDUPE_KEY_LENGTH,
50
+ DEDUPE_MARKER_PATTERN,
51
+ DEDUPE_MARKER_TEMPLATE,
52
+ DEFAULT_SOURCE,
53
+ DUPLICATE_MESSAGE_TEMPLATE,
54
+ EMPTY_TEXT_MESSAGE,
55
+ FAILURE_EXIT_CODE,
56
+ FILED_MESSAGE_TEMPLATE,
57
+ GH_EXECUTABLE,
58
+ LABEL_FIELD_TEMPLATE,
59
+ LINE_SEPARATOR,
60
+ ISSUE_BODY_TEMPLATE,
61
+ ISSUES_CREATE_PATH_TEMPLATE,
62
+ ISSUES_LIST_JQ_FILTER,
63
+ ISSUES_LIST_PATH_TEMPLATE,
64
+ LEDGER_FILE_NAME,
65
+ LEDGER_KEY_TEMPLATE,
66
+ ISSUE_TITLE_PREFIX,
67
+ ISSUE_TITLE_TEXT_LENGTH,
68
+ LIST_LINE_TEMPLATE,
69
+ MISSING_CONFIG_MESSAGE_TEMPLATE,
70
+ NO_OPEN_CORRECTIONS_MESSAGE,
71
+ REDACTION_PLACEHOLDER,
72
+ SUCCESS_EXIT_CODE,
73
+ TITLE_FIELD_TEMPLATE,
74
+ UTF8_ENCODING,
75
+ WORD_SEPARATOR,
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class FilingTarget:
81
+ """The repository and label a correction files into."""
82
+
83
+ repository: str
84
+ label: str
85
+
86
+
87
+ class FilingConfigMissing(ValueError):
88
+ """Stop the run when the private config file is absent or incomplete."""
89
+
90
+
91
+ def _config_path() -> Path:
92
+ """Return the config path from the environment, or the default under ~/.claude."""
93
+ configured_path = os.environ.get(CONFIG_PATH_ENV_VAR, "")
94
+ if configured_path:
95
+ return Path(configured_path)
96
+ return Path.home() / CLAUDE_DIRECTORY_NAME / CONFIG_FILE_NAME
97
+
98
+
99
+ def load_filing_target(from_path: Path) -> FilingTarget:
100
+ """Read the repository and label from the config file.
101
+
102
+ Args:
103
+ from_path: the private config file.
104
+
105
+ Returns:
106
+ The repository and label to file into.
107
+
108
+ Raises:
109
+ FilingConfigMissing: the file is absent, unreadable, or lacks a key.
110
+ """
111
+ missing_message = MISSING_CONFIG_MESSAGE_TEMPLATE.format(path=from_path)
112
+ try:
113
+ parsed_config = json.loads(from_path.read_text(encoding=CONFIG_ENCODING))
114
+ except (OSError, ValueError) as error:
115
+ raise FilingConfigMissing(missing_message) from error
116
+ if not isinstance(parsed_config, dict):
117
+ raise FilingConfigMissing(missing_message)
118
+ repository = parsed_config.get(CONFIG_REPOSITORY_KEY, "")
119
+ label = parsed_config.get(CONFIG_LABEL_KEY, "")
120
+ if not isinstance(repository, str) or not isinstance(label, str):
121
+ raise FilingConfigMissing(missing_message)
122
+ if not repository or not label:
123
+ raise FilingConfigMissing(missing_message)
124
+ return FilingTarget(repository=repository, label=label)
125
+
126
+
127
+ def _redact_sensitive_text(correction_text: str) -> str:
128
+ """Mask tokens, keys, and email addresses in the correction text."""
129
+ redacted_text = correction_text
130
+ for each_pattern in ALL_REDACTION_PATTERNS:
131
+ redacted_text = re.sub(each_pattern, REDACTION_PLACEHOLDER, redacted_text)
132
+ return redacted_text
133
+
134
+
135
+ def _dedupe_key_for(correction_text: str, supplied_key: str) -> str:
136
+ """Return a hex key that names this correction across replays."""
137
+ key_source = supplied_key or WORD_SEPARATOR.join(correction_text.split())
138
+ full_digest = hashlib.sha256(key_source.encode(UTF8_ENCODING)).hexdigest()
139
+ return full_digest[:DEDUPE_KEY_LENGTH]
140
+
141
+
142
+ def issue_title_for(correction_text: str) -> str:
143
+ """Return a one-line issue title cut from the correction text.
144
+
145
+ Args:
146
+ correction_text: the redacted correction.
147
+
148
+ Returns:
149
+ The prefix and the first characters of the text on one line.
150
+ """
151
+ single_line_text = WORD_SEPARATOR.join(correction_text.split())
152
+ return ISSUE_TITLE_PREFIX + single_line_text[:ISSUE_TITLE_TEXT_LENGTH]
153
+
154
+
155
+ def issue_body_for(correction_text: str, source: str, dedupe_key: str) -> str:
156
+ """Return the issue body with the quoted correction, its source, and the marker.
157
+
158
+ Args:
159
+ correction_text: the redacted correction.
160
+ source: where the correction came from.
161
+ dedupe_key: the hex key the marker carries.
162
+
163
+ Returns:
164
+ The markdown issue body.
165
+ """
166
+ quoted_text = LINE_SEPARATOR.join(
167
+ "> " + each_line for each_line in correction_text.splitlines()
168
+ )
169
+ return ISSUE_BODY_TEMPLATE.format(
170
+ source=source,
171
+ quoted_text=quoted_text,
172
+ dedupe_marker=DEDUPE_MARKER_TEMPLATE.format(key=dedupe_key),
173
+ )
174
+
175
+
176
+ def _run_gh(all_arguments: list[str]) -> str:
177
+ completed_process = subprocess.run(
178
+ [GH_EXECUTABLE, *all_arguments],
179
+ capture_output=True,
180
+ text=True,
181
+ encoding=UTF8_ENCODING,
182
+ check=True,
183
+ )
184
+ return completed_process.stdout
185
+
186
+
187
+ def _list_labeled_issues(target: FilingTarget, state: str) -> list[dict[str, object]]:
188
+ """Return the issues carrying the label, with number, title, url, and body."""
189
+ issues_path = ISSUES_LIST_PATH_TEMPLATE.format(
190
+ repository=target.repository, label=quote(target.label), state=state
191
+ )
192
+ issue_lines = _run_gh(["api", "--paginate", issues_path, "--jq", ISSUES_LIST_JQ_FILTER])
193
+ return [json.loads(each_line) for each_line in issue_lines.splitlines() if each_line.strip()]
194
+
195
+
196
+ def _ledger_path() -> Path:
197
+ return _config_path().with_name(LEDGER_FILE_NAME)
198
+
199
+
200
+ def _read_ledger() -> dict[str, str]:
201
+ try:
202
+ parsed_ledger = json.loads(_ledger_path().read_text(encoding=CONFIG_ENCODING))
203
+ except (OSError, ValueError):
204
+ return {}
205
+ return parsed_ledger if isinstance(parsed_ledger, dict) else {}
206
+
207
+
208
+ def _ledger_key_for(target: FilingTarget, dedupe_key: str) -> str:
209
+ return LEDGER_KEY_TEMPLATE.format(repository=target.repository, dedupe_key=dedupe_key)
210
+
211
+
212
+ def _record_in_ledger(target: FilingTarget, dedupe_key: str, issue_url: str) -> None:
213
+ url_by_ledger_key = _read_ledger()
214
+ url_by_ledger_key[_ledger_key_for(target, dedupe_key)] = issue_url
215
+ _ledger_path().write_text(json.dumps(url_by_ledger_key), encoding=CONFIG_ENCODING)
216
+
217
+
218
+ def _find_filed_issue_url(target: FilingTarget, dedupe_key: str) -> str:
219
+ """Return the url of an issue already carrying this dedupe key, or empty."""
220
+ ledger_url = _read_ledger().get(_ledger_key_for(target, dedupe_key), "")
221
+ if ledger_url:
222
+ return ledger_url
223
+ for each_issue in _list_labeled_issues(target, "all"):
224
+ each_match = re.search(DEDUPE_MARKER_PATTERN, str(each_issue.get("body", "")))
225
+ if each_match and each_match.group(1) == dedupe_key:
226
+ return str(each_issue.get("url", ""))
227
+ return ""
228
+
229
+
230
+ def _file_correction(
231
+ target: FilingTarget, correction_text: str, source: str, supplied_key: str
232
+ ) -> str:
233
+ """File the correction once and return the message to print."""
234
+ redacted_text = _redact_sensitive_text(correction_text.strip())
235
+ dedupe_key = _dedupe_key_for(redacted_text, supplied_key)
236
+ existing_url = _find_filed_issue_url(target, dedupe_key)
237
+ if existing_url:
238
+ return DUPLICATE_MESSAGE_TEMPLATE.format(url=existing_url)
239
+ created_url = _run_gh(
240
+ [
241
+ "api",
242
+ "--method",
243
+ "POST",
244
+ ISSUES_CREATE_PATH_TEMPLATE.format(repository=target.repository),
245
+ "-f",
246
+ TITLE_FIELD_TEMPLATE.format(value=issue_title_for(redacted_text)),
247
+ "-f",
248
+ BODY_FIELD_TEMPLATE.format(value=issue_body_for(redacted_text, source, dedupe_key)),
249
+ "-f",
250
+ LABEL_FIELD_TEMPLATE.format(value=target.label),
251
+ "--jq",
252
+ CREATED_URL_JQ_FILTER,
253
+ ]
254
+ ).strip()
255
+ _record_in_ledger(target, dedupe_key, created_url)
256
+ return FILED_MESSAGE_TEMPLATE.format(url=created_url)
257
+
258
+
259
+ def _open_corrections_listing(target: FilingTarget) -> str:
260
+ """Return one line per open labeled issue, or a no-open line."""
261
+ all_open_issues = _list_labeled_issues(target, "open")
262
+ if not all_open_issues:
263
+ return NO_OPEN_CORRECTIONS_MESSAGE
264
+ return "".join(
265
+ LIST_LINE_TEMPLATE.format(
266
+ number=each_issue["number"],
267
+ title=each_issue["title"],
268
+ url=each_issue["url"],
269
+ )
270
+ for each_issue in all_open_issues
271
+ )
272
+
273
+
274
+ def _build_parser() -> argparse.ArgumentParser:
275
+ parser = argparse.ArgumentParser(description="File or list user corrections.")
276
+ all_subcommands = parser.add_subparsers(dest="command", required=True)
277
+ file_parser = all_subcommands.add_parser("file", help="File one correction.")
278
+ file_parser.add_argument("--text", default=None)
279
+ file_parser.add_argument("--source", choices=ALL_SOURCES, default=DEFAULT_SOURCE)
280
+ file_parser.add_argument("--dedupe-key", default="")
281
+ all_subcommands.add_parser("list", help="List the open corrections.")
282
+ return parser
283
+
284
+
285
+ def _run_command(target: FilingTarget, parsed_arguments: argparse.Namespace) -> int:
286
+ if parsed_arguments.command == "list":
287
+ sys.stdout.write(_open_corrections_listing(target))
288
+ return SUCCESS_EXIT_CODE
289
+ correction_text = (
290
+ sys.stdin.buffer.read().decode(UTF8_ENCODING)
291
+ if parsed_arguments.text is None
292
+ else parsed_arguments.text
293
+ )
294
+ if not correction_text.strip():
295
+ sys.stderr.write(EMPTY_TEXT_MESSAGE)
296
+ return FAILURE_EXIT_CODE
297
+ sys.stdout.write(
298
+ _file_correction(
299
+ target, correction_text, parsed_arguments.source, parsed_arguments.dedupe_key
300
+ )
301
+ )
302
+ return SUCCESS_EXIT_CODE
303
+
304
+
305
+ def main(all_arguments: list[str]) -> int:
306
+ """Run the file or list command.
307
+
308
+ Args:
309
+ all_arguments: the command-line arguments after the script name.
310
+
311
+ Returns:
312
+ The process exit code.
313
+ """
314
+ parsed_arguments = _build_parser().parse_args(all_arguments)
315
+ try:
316
+ target = load_filing_target(_config_path())
317
+ except FilingConfigMissing as error:
318
+ sys.stderr.write(str(error))
319
+ return FAILURE_EXIT_CODE
320
+ try:
321
+ return _run_command(target, parsed_arguments)
322
+ except subprocess.CalledProcessError as error:
323
+ sys.stderr.write(error.stderr or str(error))
324
+ return FAILURE_EXIT_CODE
325
+
326
+ if __name__ == "__main__":
327
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,56 @@
1
+ """Configuration constants for the correction filing command."""
2
+
3
+ ALL_REDACTION_PATTERNS = (
4
+ r"gh[pousr]_[A-Za-z0-9]{20,}",
5
+ r"github_pat_[A-Za-z0-9_]{20,}",
6
+ r"sk-[A-Za-z0-9_-]{20,}",
7
+ r"xox[abprs]-[A-Za-z0-9-]{10,}",
8
+ r"AKIA[0-9A-Z]{16}",
9
+ r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
10
+ )
11
+ BODY_FIELD_TEMPLATE = "body={value}"
12
+ ALL_SOURCES = ("typed", "flag", "reaction", "hook")
13
+ CLAUDE_DIRECTORY_NAME = ".claude"
14
+ CONFIG_ENCODING = "utf-8"
15
+ CONFIG_FILE_NAME = "correction-capture.json"
16
+ CONFIG_LABEL_KEY = "label"
17
+ CONFIG_PATH_ENV_VAR = "CLAUDE_CORRECTION_CAPTURE_PATH"
18
+ CONFIG_REPOSITORY_KEY = "repository"
19
+ CREATED_URL_JQ_FILTER = ".html_url"
20
+ DEDUPE_MARKER_PATTERN = r"<!-- correction-dedupe: ([0-9a-f]{16,64}) -->"
21
+ DEDUPE_MARKER_TEMPLATE = "<!-- correction-dedupe: {key} -->"
22
+ DEDUPE_KEY_LENGTH = 16
23
+ DEFAULT_SOURCE = "typed"
24
+ DUPLICATE_MESSAGE_TEMPLATE = "Already filed: {url}\n"
25
+ EMPTY_TEXT_MESSAGE = "The correction text is empty. Nothing was filed.\n"
26
+ FAILURE_EXIT_CODE = 1
27
+ FILED_MESSAGE_TEMPLATE = "Filed: {url}\n"
28
+ GH_EXECUTABLE = "gh"
29
+ ISSUE_BODY_TEMPLATE = (
30
+ "A correction the user made to an agent.\n\n"
31
+ "Source: {source}\n\n"
32
+ "{quoted_text}\n\n"
33
+ "{dedupe_marker}\n"
34
+ )
35
+ ISSUES_CREATE_PATH_TEMPLATE = "repos/{repository}/issues"
36
+ ISSUES_LIST_JQ_FILTER = (
37
+ ".[] | select(.pull_request | not) | {number, title, url: .html_url, body}"
38
+ )
39
+ ISSUES_LIST_PATH_TEMPLATE = "repos/{repository}/issues?labels={label}&state={state}&per_page=100"
40
+ ISSUE_TITLE_PREFIX = "Correction: "
41
+ ISSUE_TITLE_TEXT_LENGTH = 72
42
+ LABEL_FIELD_TEMPLATE = "labels[]={value}"
43
+ LEDGER_FILE_NAME = "correction-capture-filed.json"
44
+ LEDGER_KEY_TEMPLATE = "{repository} {dedupe_key}"
45
+ LINE_SEPARATOR = "\n"
46
+ LIST_LINE_TEMPLATE = "#{number} {title} {url}\n"
47
+ MISSING_CONFIG_MESSAGE_TEMPLATE = (
48
+ "No correction filing config at {path}. Write it as "
49
+ '{{"repository": "<owner>/<name>", "label": "<label>"}}. Nothing was filed.\n'
50
+ )
51
+ NO_OPEN_CORRECTIONS_MESSAGE = "No open corrections.\n"
52
+ REDACTION_PLACEHOLDER = "[redacted]"
53
+ SUCCESS_EXIT_CODE = 0
54
+ TITLE_FIELD_TEMPLATE = "title={value}"
55
+ UTF8_ENCODING = "utf-8"
56
+ WORD_SEPARATOR = " "
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ python_files = test_*.py
3
+ python_classes = Test*
4
+ python_functions = test_* should_*
@@ -0,0 +1,155 @@
1
+ """Behavior tests for correction_filing with a stand-in gh on PATH."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import io
7
+ import json
8
+ import os
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
16
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
17
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
18
+
19
+ correction_filing = importlib.import_module("correction_filing")
20
+
21
+ STAND_IN_GH = r'''#!/usr/bin/env python3
22
+ import json, os, sys
23
+ from urllib.parse import parse_qs, urlsplit
24
+ store = os.environ["STAND_IN_GH_STORE"]
25
+ issues = json.load(open(store)) if os.path.exists(store) else []
26
+ args = sys.argv[1:]
27
+ if "POST" in args:
28
+ fields = dict(args[i + 1].split("=", 1) for i, a in enumerate(args) if a == "-f")
29
+ number = len(issues) + 1
30
+ url = "https://example.test/issues/%d" % number
31
+ issues.append({"number": number, "title": fields["title"], "url": url,
32
+ "body": fields["body"], "label": fields["labels[]"], "state": "open"})
33
+ json.dump(issues, open(store, "w"))
34
+ print(url)
35
+ else:
36
+ query = parse_qs(urlsplit(args[2]).query)
37
+ state = query["state"][0]
38
+ for i in issues:
39
+ if i["label"] == query["labels"][0] and (state == "all" or i["state"] == state):
40
+ print(json.dumps({k: i[k] for k in ("number", "title", "url", "body")}))
41
+ '''
42
+
43
+
44
+ @pytest.fixture
45
+ def filing_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
46
+ bin_directory = tmp_path / "bin"
47
+ bin_directory.mkdir()
48
+ gh_path = bin_directory / "gh"
49
+ gh_path.write_text(STAND_IN_GH, encoding="utf-8")
50
+ gh_path.chmod(gh_path.stat().st_mode | stat.S_IEXEC)
51
+ store_path = tmp_path / "issues.json"
52
+ config_file = tmp_path / "correction-capture.json"
53
+ config_file.write_text(json.dumps({"repository": "owner/name", "label": "correction"}))
54
+ monkeypatch.setenv("PATH", str(bin_directory) + os.pathsep + os.environ["PATH"])
55
+ monkeypatch.setenv("STAND_IN_GH_STORE", str(store_path))
56
+ monkeypatch.setenv("CLAUDE_CORRECTION_CAPTURE_PATH", str(config_file))
57
+ return store_path
58
+
59
+
60
+ def _stored_issues(store_path: Path) -> list[dict[str, object]]:
61
+ return json.loads(store_path.read_text()) if store_path.exists() else []
62
+
63
+
64
+ def test_should_file_one_labeled_issue_quoting_the_text(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
65
+ assert correction_filing.main(["file", "--text", "use shorter replies"]) == 0
66
+ all_issues = _stored_issues(filing_environment)
67
+ assert len(all_issues) == 1
68
+ assert all_issues[0]["label"] == "correction"
69
+ assert "> use shorter replies" in all_issues[0]["body"]
70
+ assert "Filed: https://example.test/issues/1" in capsys.readouterr().out
71
+
72
+
73
+ def test_should_file_a_replayed_correction_once(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
74
+ correction_filing.main(["file", "--text", "stop asking me"])
75
+ assert correction_filing.main(["file", "--text", "stop asking me"]) == 0
76
+ assert len(_stored_issues(filing_environment)) == 1
77
+ assert "Already filed: https://example.test/issues/1" in capsys.readouterr().out
78
+
79
+
80
+ def test_should_dedupe_on_a_supplied_key(filing_environment: Path) -> None:
81
+ correction_filing.main(["file", "--text", "first", "--dedupe-key", "msg-7"])
82
+ correction_filing.main(["file", "--text", "second", "--dedupe-key", "msg-7"])
83
+ correction_filing.main(["file", "--text", "second", "--dedupe-key", "msg-8"])
84
+ assert len(_stored_issues(filing_environment)) == 2
85
+
86
+
87
+ def test_should_mask_tokens_and_emails(filing_environment: Path) -> None:
88
+ correction_filing.main(
89
+ ["file", "--text", "you leaked ghp_" + "a" * 36 + " and me@example.com"]
90
+ )
91
+ body = _stored_issues(filing_environment)[0]["body"]
92
+ assert "ghp_" not in body and "me@example.com" not in body
93
+ assert body.count("[redacted]") == 2
94
+
95
+
96
+ def test_should_file_nothing_without_config(filing_environment: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
97
+ monkeypatch.setenv("CLAUDE_CORRECTION_CAPTURE_PATH", str(tmp_path / "absent.json"))
98
+ assert correction_filing.main(["file", "--text", "anything"]) == 1
99
+ assert _stored_issues(filing_environment) == []
100
+ assert "No correction filing config" in capsys.readouterr().err
101
+
102
+
103
+ def test_should_file_nothing_for_empty_text(filing_environment: Path) -> None:
104
+ assert correction_filing.main(["file", "--text", " "]) == 1
105
+ assert _stored_issues(filing_environment) == []
106
+
107
+
108
+ def test_should_list_open_corrections(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
109
+ correction_filing.main(["list"])
110
+ assert "No open corrections." in capsys.readouterr().out
111
+ correction_filing.main(["file", "--text", "use plain words"])
112
+ capsys.readouterr()
113
+ correction_filing.main(["list"])
114
+ assert "#1 Correction: use plain words https://example.test/issues/1" in capsys.readouterr().out
115
+
116
+
117
+ def test_should_build_title_and_body_from_text() -> None:
118
+ assert correction_filing.issue_title_for("a\nb") == "Correction: a b"
119
+ body = correction_filing.issue_body_for("x\ny", "flag", "abcdef0123456789")
120
+ assert "> x\n> y" in body and "Source: flag" in body
121
+ assert "<!-- correction-dedupe: abcdef0123456789 -->" in body
122
+
123
+
124
+ def test_should_read_target_and_reject_incomplete_config(tmp_path: Path) -> None:
125
+ good = tmp_path / "good.json"
126
+ good.write_text(json.dumps({"repository": "o/n", "label": "l"}))
127
+ assert correction_filing.load_filing_target(good) == correction_filing.FilingTarget("o/n", "l")
128
+ bad = tmp_path / "bad.json"
129
+ bad.write_text(json.dumps({"repository": "o/n"}))
130
+ with pytest.raises(correction_filing.FilingConfigMissing):
131
+ correction_filing.load_filing_target(bad)
132
+
133
+
134
+ def test_should_dedupe_from_the_ledger_when_the_listing_lags(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
135
+ correction_filing.main(["file", "--text", "keep replies short"])
136
+ filing_environment.write_text("[]")
137
+ capsys.readouterr()
138
+ correction_filing.main(["file", "--text", "keep replies short"])
139
+ assert "Already filed: https://example.test/issues/1" in capsys.readouterr().out
140
+
141
+
142
+ def test_should_read_the_text_from_stdin_without_expanding_it(filing_environment: Path, monkeypatch: pytest.MonkeyPatch) -> None:
143
+ piped_bytes = 'say "$(whoami)" and `id` in caf\u00e9\n'.encode("utf-8")
144
+ monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(piped_bytes), encoding="cp1252"))
145
+ assert correction_filing.main(["file"]) == 0
146
+ assert '> say "$(whoami)" and `id` in caf\u00e9' in _stored_issues(filing_environment)[0]["body"]
147
+
148
+
149
+ def test_should_file_again_after_the_config_names_another_repository(filing_environment: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
150
+ correction_filing.main(["file", "--text", "keep replies short"])
151
+ filing_environment.write_text("[]")
152
+ (tmp_path / "correction-capture.json").write_text(json.dumps({"repository": "owner/other", "label": "correction"}))
153
+ capsys.readouterr()
154
+ correction_filing.main(["file", "--text", "keep replies short"])
155
+ assert "Filed: https://example.test/issues/1" in capsys.readouterr().out
@@ -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())
@@ -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
+ )
@@ -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.
@@ -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.
package/hooks/hooks.json CHANGED
@@ -97,6 +97,16 @@
97
97
  }
98
98
  ]
99
99
  },
100
+ {
101
+ "matcher": "Read",
102
+ "hooks": [
103
+ {
104
+ "type": "command",
105
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/image_read_size_gate.py",
106
+ "timeout": 10
107
+ }
108
+ ]
109
+ },
100
110
  {
101
111
  "matcher": "EnterWorktree",
102
112
  "hooks": [
@@ -0,0 +1,41 @@
1
+ """Names, limits and refusal text for the image_read_size_gate PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ READ_TOOL_NAME = "Read"
8
+ TOOL_NAME_KEY = "tool_name"
9
+ TOOL_INPUT_KEY = "tool_input"
10
+ FILE_PATH_INPUT_KEY = "file_path"
11
+ PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
12
+ DENY_DECISION = "deny"
13
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
14
+ MAXIMUM_LONG_EDGE_PIXELS = 512
15
+ HEADER_BYTE_COUNT = 64 * 1024
16
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
17
+ PNG_SIZE_START = 16
18
+ PNG_SIZE_END = 24
19
+ GIF_SIZE_START = 6
20
+ GIF_SIZE_END = 10
21
+ ALL_GIF_SIGNATURES = (b"GIF87a", b"GIF89a")
22
+ JPEG_SIGNATURE = b"\xff\xd8"
23
+ JPEG_MARKER_PREFIX = 0xFF
24
+ JPEG_MARKER_LENGTH = 2
25
+ JPEG_FRAME_SIZE_START = 5
26
+ JPEG_FRAME_SIZE_END = 9
27
+ ALL_JPEG_FRAME_MARKERS = frozenset(
28
+ {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
29
+ )
30
+ ALL_JPEG_STANDALONE_MARKERS = frozenset(
31
+ {0x01, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8}
32
+ )
33
+ AGENT_COPY_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "agent_image_copy.py"
34
+ OVERSIZED_IMAGE_REASON_TEMPLATE = (
35
+ "This image is {width}x{height} pixels. Agents read images at {maximum_edge} pixels or less"
36
+ " on the long edge, because a full-size image fills the context: a 1440x2560 screenshot costs"
37
+ " about 4,784 tokens, and a 288x512 copy costs about 209. Write a capped copy and read that"
38
+ ' copy: `python3 "{script_path}" "{image_path}"` prints the path of the copy, which keeps'
39
+ " the aspect ratio. Crop the region you need with `--crop LEFT,TOP,RIGHT,BOTTOM` when a"
40
+ " detail is too small at that size. Send the full-size file to a person, never to an agent."
41
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.69.1",
3
+ "version": "8.70.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,155 @@
1
+ """Write a copy of an image small enough for an agent to read.
2
+
3
+ The image_read_size_gate hook refuses a Read of an image whose long edge is over
4
+ 512 pixels and names this script. The script writes ``<name>.agent.png`` next to
5
+ the source, scaled so the long edge is 512 pixels with the aspect ratio kept,
6
+ and prints its path::
7
+
8
+ python3 agent_image_copy.py shot.png -> shot.agent.png (288x512)
9
+ python3 agent_image_copy.py shot.png --crop 0,0,720,640
10
+ -> the top-left region, capped
11
+ python3 agent_image_copy.py icon.png (200x200) -> icon.agent.png, same size
12
+
13
+ The copy is newer than the source, so a check that wants a screenshot read
14
+ after a page changed still counts it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import math
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ scripts_directory = str(Path(__file__).resolve().parent)
25
+ if scripts_directory not in sys.path:
26
+ sys.path.insert(0, scripts_directory)
27
+
28
+ from dev_env_scripts_constants.agent_image_copy_constants import (
29
+ AGENT_COPY_SUFFIX,
30
+ ALL_PNG_WRITABLE_MODES,
31
+ CROP_BOX_PART_COUNT,
32
+ CROP_BOX_SEPARATOR,
33
+ MAXIMUM_LONG_EDGE_PIXELS,
34
+ PNG_FALLBACK_MODE,
35
+ VISUAL_TOKEN_PATCH_PIXELS,
36
+ )
37
+ from PIL import Image, ImageOps
38
+
39
+
40
+ def capped_size(
41
+ width: int, height: int, maximum_edge: int = MAXIMUM_LONG_EDGE_PIXELS
42
+ ) -> tuple[int, int]:
43
+ """Return the size that fits the long edge into maximum_edge with the aspect ratio kept.
44
+
45
+ ::
46
+
47
+ capped_size(1440, 2560) -> (288, 512)
48
+ capped_size(2000, 1000) -> (512, 256)
49
+ capped_size(300, 200) -> (300, 200)
50
+
51
+ Args:
52
+ width: Source width in pixels.
53
+ height: Source height in pixels.
54
+ maximum_edge: The largest long edge allowed.
55
+
56
+ Returns:
57
+ The capped (width, height).
58
+ """
59
+ long_edge = max(width, height)
60
+ if long_edge <= maximum_edge:
61
+ return width, height
62
+ scale = maximum_edge / long_edge
63
+ return max(1, round(width * scale)), max(1, round(height * scale))
64
+
65
+
66
+ def visual_tokens(width: int, height: int) -> int:
67
+ """Return the documented visual-token cost of an image: one token per 28x28 patch.
68
+
69
+ ::
70
+
71
+ visual_tokens(1440, 2560) -> 4784
72
+ visual_tokens(288, 512) -> 209
73
+
74
+ Args:
75
+ width: Image width in pixels as the model sees it.
76
+ height: Image height in pixels as the model sees it.
77
+ """
78
+ return math.ceil(width / VISUAL_TOKEN_PATCH_PIXELS) * math.ceil(
79
+ height / VISUAL_TOKEN_PATCH_PIXELS
80
+ )
81
+
82
+
83
+ def parse_crop_box(crop_text: str) -> tuple[int, int, int, int]:
84
+ """Return LEFT, TOP, RIGHT, BOTTOM parsed from text like ``0,0,720,640``.
85
+
86
+ Args:
87
+ crop_text: Four comma-separated pixel coordinates.
88
+
89
+ Returns:
90
+ The four coordinates as integers.
91
+
92
+ Raises:
93
+ argparse.ArgumentTypeError: When the text does not hold four numbers.
94
+ """
95
+ all_parts = crop_text.split(CROP_BOX_SEPARATOR)
96
+ if len(all_parts) != CROP_BOX_PART_COUNT:
97
+ raise argparse.ArgumentTypeError(
98
+ f"--crop takes LEFT,TOP,RIGHT,BOTTOM, got {crop_text!r}"
99
+ )
100
+ left, top, right, bottom = (int(each_part) for each_part in all_parts)
101
+ return left, top, right, bottom
102
+
103
+
104
+ def write_agent_copy(
105
+ source_path: Path, all_crop_coordinates: tuple[int, int, int, int] | None
106
+ ) -> Path:
107
+ """Write the capped copy beside the source and return its path.
108
+
109
+ The copy is turned upright by its EXIF orientation before the crop, and a mode PNG
110
+ cannot hold, such as CMYK, is converted to RGB.
111
+
112
+ Args:
113
+ source_path: The full-size image.
114
+ all_crop_coordinates: A LEFT, TOP, RIGHT, BOTTOM region cut before scaling, or None for the whole image.
115
+
116
+ Returns:
117
+ The path of the written copy.
118
+ """
119
+ copy_path = source_path.with_name(source_path.stem + AGENT_COPY_SUFFIX)
120
+ with Image.open(source_path) as source_image:
121
+ upright_image = ImageOps.exif_transpose(source_image)
122
+ working_image = upright_image.crop(all_crop_coordinates) if all_crop_coordinates else upright_image.copy()
123
+ if working_image.mode not in ALL_PNG_WRITABLE_MODES:
124
+ working_image = working_image.convert(PNG_FALLBACK_MODE)
125
+ working_image = working_image.resize(
126
+ capped_size(*working_image.size), Image.Resampling.LANCZOS
127
+ )
128
+ working_image.save(copy_path, optimize=True)
129
+ return copy_path
130
+
131
+
132
+ def main(all_arguments: list[str]) -> int:
133
+ """Write the capped copy named on the command line and print its path and token cost.
134
+
135
+ Args:
136
+ all_arguments: Command-line arguments after the program name.
137
+
138
+ Returns:
139
+ 0 when the copy was written.
140
+ """
141
+ parser = argparse.ArgumentParser(
142
+ description="Write an agent-size copy of an image."
143
+ )
144
+ parser.add_argument("image_path", type=Path)
145
+ parser.add_argument("--crop", type=parse_crop_box, default=None)
146
+ parsed = parser.parse_args(all_arguments)
147
+ copy_path = write_agent_copy(parsed.image_path, parsed.crop)
148
+ with Image.open(copy_path) as copy_image:
149
+ width, height = copy_image.size
150
+ print(f"{copy_path} {width}x{height} about {visual_tokens(width, height)} tokens")
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,11 @@
1
+ """Limits and names for the agent_image_copy script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAXIMUM_LONG_EDGE_PIXELS = 512
6
+ VISUAL_TOKEN_PATCH_PIXELS = 28
7
+ AGENT_COPY_SUFFIX = ".agent.png"
8
+ CROP_BOX_SEPARATOR = ","
9
+ CROP_BOX_PART_COUNT = 4
10
+ ALL_PNG_WRITABLE_MODES = frozenset({"1", "L", "LA", "I", "I;16", "P", "RGB", "RGBA"})
11
+ PNG_FALLBACK_MODE = "RGB"
@@ -71,6 +71,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
71
71
  "blocking/verify_before_acting.py",
72
72
  "blocking/session_title_format_gate.py",
73
73
  "blocking/session_title_stop_gate.py",
74
+ "blocking/image_read_size_gate.py",
74
75
  "blocking/artifact_dark_mode_gate.py",
75
76
  "blocking/artifact_look_gate.py",
76
77
  )
@@ -0,0 +1,76 @@
1
+ """Behavior tests for the agent image copy script."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from PIL import Image
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+
12
+ import agent_image_copy
13
+
14
+
15
+ def test_capped_size_should_keep_the_aspect_ratio() -> None:
16
+ assert agent_image_copy.capped_size(1440, 2560) == (288, 512)
17
+ assert agent_image_copy.capped_size(2000, 1000) == (512, 256)
18
+ assert agent_image_copy.capped_size(300, 200) == (300, 200)
19
+
20
+
21
+ def test_visual_tokens_should_count_28_pixel_patches() -> None:
22
+ assert agent_image_copy.visual_tokens(1440, 2560) == 4784
23
+ assert agent_image_copy.visual_tokens(288, 512) == 209
24
+
25
+
26
+ def test_parse_crop_box_should_refuse_three_numbers() -> None:
27
+ assert agent_image_copy.parse_crop_box("0,0,10,20") == (0, 0, 10, 20)
28
+ with pytest.raises(argparse.ArgumentTypeError):
29
+ agent_image_copy.parse_crop_box("0,0,10")
30
+
31
+
32
+ def test_write_agent_copy_should_write_a_capped_newer_file(tmp_path: Path) -> None:
33
+ source_path = tmp_path / "preview.png"
34
+ Image.new("RGB", (1440, 2560), "white").save(source_path)
35
+ copy_path = agent_image_copy.write_agent_copy(source_path, None)
36
+ assert copy_path.name == "preview.agent.png"
37
+ with Image.open(copy_path) as copy_image:
38
+ assert copy_image.size == (288, 512)
39
+ assert copy_path.stat().st_mtime >= source_path.stat().st_mtime
40
+
41
+
42
+ def test_write_agent_copy_should_crop_before_scaling(tmp_path: Path) -> None:
43
+ source_path = tmp_path / "preview.png"
44
+ Image.new("RGB", (1440, 2560), "white").save(source_path)
45
+ copy_path = agent_image_copy.write_agent_copy(source_path, (0, 0, 720, 640))
46
+ with Image.open(copy_path) as copy_image:
47
+ assert copy_image.size == (512, 455)
48
+
49
+
50
+ def test_write_agent_copy_should_write_a_cmyk_jpeg_as_rgb(tmp_path: Path) -> None:
51
+ source_path = tmp_path / "print.jpg"
52
+ Image.new("CMYK", (1000, 800)).save(source_path)
53
+ copy_path = agent_image_copy.write_agent_copy(source_path, None)
54
+ with Image.open(copy_path) as copy_image:
55
+ assert copy_image.mode == "RGB"
56
+ assert copy_image.size == (512, 410)
57
+
58
+
59
+ def test_write_agent_copy_should_follow_the_exif_orientation(tmp_path: Path) -> None:
60
+ source_path = tmp_path / "phone.jpg"
61
+ source_image = Image.new("RGB", (1000, 600), "white")
62
+ all_exif_tags = source_image.getexif()
63
+ all_exif_tags[0x0112] = 6
64
+ source_image.save(source_path, exif=all_exif_tags)
65
+ copy_path = agent_image_copy.write_agent_copy(source_path, None)
66
+ with Image.open(copy_path) as copy_image:
67
+ assert copy_image.size == (307, 512)
68
+
69
+
70
+ def test_main_should_print_the_copy_path(
71
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
72
+ ) -> None:
73
+ source_path = tmp_path / "shot.png"
74
+ Image.new("RGB", (1600, 900), "white").save(source_path)
75
+ assert agent_image_copy.main([str(source_path)]) == 0
76
+ assert "shot.agent.png 512x288 about 209 tokens" in capsys.readouterr().out