claude-dev-env 8.69.1 → 8.71.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 (26) 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/docs/templates/html-plan/README.md +36 -0
  10. package/docs/templates/html-plan/house.css +26 -0
  11. package/hooks/blocking/image_read_size_gate.py +167 -0
  12. package/hooks/blocking/test_image_read_size_gate.py +79 -0
  13. package/hooks/features/README.md +1 -0
  14. package/hooks/features/read-gates.md +20 -0
  15. package/hooks/features/session-context.md +2 -2
  16. package/hooks/hooks.json +10 -0
  17. package/hooks/hooks_constants/artifact_template_pointer_constants.py +9 -3
  18. package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
  19. package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +9 -1
  20. package/hooks/session/artifact_template_pointer.py +10 -4
  21. package/hooks/session/test_artifact_template_pointer.py +8 -2
  22. package/package.json +1 -1
  23. package/scripts/agent_image_copy.py +155 -0
  24. package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
  25. package/scripts/policy_lint/config/constants.py +1 -0
  26. package/scripts/test_agent_image_copy.py +76 -0
@@ -0,0 +1,26 @@
1
+ :root { color-scheme: light dark; --ink-2: var(--ink); --ink-3: var(--ink); --accent: #7a2e14; --green: #1c5426; --amber: #5f3f06; --red: #8f1d17; --on-accent: #ffffff; }
2
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink-2: var(--ink); --ink-3: var(--ink); --accent: #f6bba5; --green: #9fdcaa; --amber: #f2cc80; --red: #f7b3ab; --on-accent: #1f1e1d; } }
3
+ :root[data-theme="dark"] { --ink-2: var(--ink); --ink-3: var(--ink); --accent: #f6bba5; --green: #9fdcaa; --amber: #f2cc80; --red: #f7b3ab; --on-accent: #1f1e1d; }
4
+ body { background: var(--paper); color: var(--ink); font: 430 16px/1.6 var(--sans); }
5
+ small, code, bdi, .mk-cap, .mk-label, .mk-bar, .fig-cap, .c-lang, .c-lines, .c-body td,
6
+ .pl-num, .pl-chev, .pl-bdg, p.pl-claim, doc-claim > .pl-row > .pl-claim, .pl-body > p, .pl-body > ul, .pl-body > doc-note,
7
+ doc-ask::before, doc-ask label small, doc-ask label .sug, doc-ask label > span,
8
+ .nw-askrow .s, .nw-askrow .q small, .nw-btn, .nw-next, .nw-respond, .nw-cbtn, .fig-zoom, .tr-name, .tr-guide, .tr-note, .cl-note, .cl-loc, .mc-to, .mc-hint { font-size: 16px !important; }
9
+ .nw-cbtn, .cl-loc, .fig-zoom, .mc-ev[disabled], .nw-btn[disabled], svg.nw .msg.mc-past path, svg.nw .msg.mc-past text { opacity: 1 !important; }
10
+ .nw-next, .has-comment > .nw-cbtn, .nw-cbtn.on, .nw-askrow.todo .s, .nw-asks .ttl b.todo, .nw-pop.note .k { color: var(--on-accent) !important; }
11
+ .mk-frame.terminal .mk-bar { color: #e6e3dc; }
12
+ doc-note, doc-note > strong:first-child { font-size: 16px !important; }
13
+ :root { --blue: #1d4a85; --purple: #4f2f96; }
14
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --blue: #b9d3f5; --purple: #d3c3f5; } }
15
+ :root[data-theme="dark"] { --blue: #b9d3f5; --purple: #d3c3f5; }
16
+ doc-note, doc-note > strong:first-child, .pill, .kicker, .dek, .meta, h4, th, table, figcaption, .caption, details > summary, pre,
17
+ .nw-toc, .nw-toc a, .nw-toc a.h3, .nw-toc a.ask, .nw-toc .ttl, .nw-pop, .nw-pop .ref, .nw-pop textarea, .nw-sheet .hint, .nw-send-state,
18
+ .nw-sheet .body pre, .nw-asks .ttl, .nw-asks .ttl b, .nw-askrow, .nw-askrow .k, .nw-toast, .nw-pin-list, .nw-err, .nw-bar .nw-respond .n,
19
+ doc-claim[data-l="1"] > .pl-row > .pl-num, doc-claim[data-l="2"] > .pl-row > .pl-claim { font-size: 16px !important; }
20
+ .has-comment > .nw-cbtn, .nw-cbtn.on, .nw-bar .nw-respond .n, .nw-bar .nw-next { background: var(--accent); }
21
+ .nw-bar .nw-respond .n.ok { background: var(--green); color: var(--on-accent); }
22
+ .nw-bar .nw-respond .n { color: var(--on-accent); }
23
+ doc-changes, doc-changes b, .ch-tag, .ch-n, .q-head, .q-via, .q-from, .q-at, .q-body { font-size: 16px !important; }
24
+ .cl-head, .cl-kind, .cl-title, .cl-counts, .cl-counts b, .cl-nm, .cl-rail, .cl-tg, .c-cap, .c-pin b, .mc-now, .mc-now b, .mc-now i, .mc-now span, .nw-pin-dot, svg.nw text, svg.nw .node text.sub, svg.nw .node text.more, svg.nw .edge text, svg.nw .group text { font-size: 16px !important; }
25
+ .cl-tg, .tr-guide { color: var(--ink); }
26
+ .nw-pin-dot { color: var(--on-accent); }
@@ -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.
@@ -10,7 +10,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
10
10
  - `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
11
11
  - `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
12
12
  - `session/issue_tracker_session_starter.py` injects issue tracker guidance when enabled for a registered repository.
13
- - `session/artifact_template_pointer.py` points an agent that starts an artifact page at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
13
+ - `session/artifact_template_pointer.py` points an agent that starts an artifact page at `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
14
14
  - `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
15
15
 
16
16
  ## When it fires
@@ -32,7 +32,7 @@ Preconditions:
32
32
  - **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
33
33
  - **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
34
34
  - **Issue tracker start.** Input is SessionStart with its opt-in flag and a registered checkout. Run `python -m pytest packages/claude-dev-env/hooks/session/test_issue_tracker_session_starter.py -q`. The adjacent test observes issue tracker context.
35
- - **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the template path in `additionalContext`, and silence on a publish call or another skill.
35
+ - **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
36
36
  - **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
37
37
 
38
38
  ## Gotchas
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": [
@@ -9,6 +9,8 @@ __all__ = [
9
9
  "ARTIFACT_QUICKSTART_ACTION",
10
10
  "ARTIFACT_TEMPLATE_PATH",
11
11
  "ARTIFACT_TOOL_NAME",
12
+ "HTML_PLAN_TEMPLATE_README_PATH",
13
+ "POINTER_ALTERNATIVE_PREFIX",
12
14
  "POINTER_TEXT_PREFIX",
13
15
  "SKILL_NAME_INPUT_KEY",
14
16
  "SKILL_TOOL_NAME",
@@ -21,7 +23,11 @@ ARTIFACT_QUICKSTART_ACTION = "quickstart"
21
23
  SKILL_TOOL_NAME = "Skill"
22
24
  SKILL_NAME_INPUT_KEY = "skill"
23
25
  ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
24
- ARTIFACT_TEMPLATE_PATH = (
25
- Path(__file__).resolve().parents[2] / "docs" / "templates" / "artifact-page" / "README.md"
26
+ TEMPLATES_DIRECTORY = Path(__file__).resolve().parents[2] / "docs" / "templates"
27
+ ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
28
+ HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
29
+ POINTER_TEXT_PREFIX = "Build this artifact page from the html-plan skill, following "
30
+ POINTER_ALTERNATIVE_PREFIX = (
31
+ ". When the request names the older artifact page template,"
32
+ " pick the one that fits the ask from "
26
33
  )
27
- POINTER_TEXT_PREFIX = "Pick the artifact page template that fits the ask from "
@@ -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
+ )
@@ -1,8 +1,16 @@
1
1
  """Behavioral tests for the artifact template pointer's path."""
2
2
 
3
- from hooks_constants.artifact_template_pointer_constants import ARTIFACT_TEMPLATE_PATH
3
+ from hooks_constants.artifact_template_pointer_constants import (
4
+ ARTIFACT_TEMPLATE_PATH,
5
+ HTML_PLAN_TEMPLATE_README_PATH,
6
+ )
4
7
 
5
8
 
6
9
  def test_template_path_names_the_packaged_template_file() -> None:
7
10
  assert ARTIFACT_TEMPLATE_PATH.is_file()
8
11
  assert ARTIFACT_TEMPLATE_PATH.parent.joinpath("README.md").is_file()
12
+
13
+
14
+ def test_html_plan_readme_path_names_the_packaged_readme_and_house_stylesheet() -> None:
15
+ assert HTML_PLAN_TEMPLATE_README_PATH.is_file()
16
+ assert HTML_PLAN_TEMPLATE_README_PATH.parent.joinpath("house.css").is_file()
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse hook: point an agent about to build an artifact page at the template.
2
+ """PreToolUse hook: point an agent about to build an artifact page at html-plan.
3
3
 
4
4
  Registered on ``Artifact|Skill``. It adds one line of ``additionalContext``
5
- naming ``docs/templates/artifact-page/template.html`` when a session starts a
6
- new artifact page:
5
+ naming ``docs/templates/html-plan/README.md`` as the default and
6
+ ``docs/templates/artifact-page/README.md`` as the alternative when a
7
+ session starts a new artifact page:
7
8
 
8
9
  ::
9
10
 
@@ -31,6 +32,8 @@ from hooks_constants.artifact_template_pointer_constants import (
31
32
  ARTIFACT_QUICKSTART_ACTION,
32
33
  ARTIFACT_TEMPLATE_PATH,
33
34
  ARTIFACT_TOOL_NAME,
35
+ HTML_PLAN_TEMPLATE_README_PATH,
36
+ POINTER_ALTERNATIVE_PREFIX,
34
37
  POINTER_TEXT_PREFIX,
35
38
  SKILL_NAME_INPUT_KEY,
36
39
  SKILL_TOOL_NAME,
@@ -77,7 +80,10 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
77
80
  return {
78
81
  HOOK_SPECIFIC_OUTPUT_KEY: {
79
82
  HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
80
- ADDITIONAL_CONTEXT_KEY: f"{POINTER_TEXT_PREFIX}{ARTIFACT_TEMPLATE_PATH}.",
83
+ ADDITIONAL_CONTEXT_KEY: (
84
+ f"{POINTER_TEXT_PREFIX}{HTML_PLAN_TEMPLATE_README_PATH}"
85
+ f"{POINTER_ALTERNATIVE_PREFIX}{ARTIFACT_TEMPLATE_PATH}."
86
+ ),
81
87
  }
82
88
  }
83
89
 
@@ -10,7 +10,10 @@ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
10
10
  sys.path.insert(0, each_sys_path_entry)
11
11
 
12
12
  import artifact_template_pointer
13
- from hooks_constants.artifact_template_pointer_constants import ARTIFACT_TEMPLATE_PATH
13
+ from hooks_constants.artifact_template_pointer_constants import (
14
+ ARTIFACT_TEMPLATE_PATH,
15
+ HTML_PLAN_TEMPLATE_README_PATH,
16
+ )
14
17
 
15
18
  HOOK_SCRIPT = Path(__file__).resolve().parent / "artifact_template_pointer.py"
16
19
 
@@ -34,7 +37,10 @@ def test_artifact_quickstart_gets_the_template_pointer() -> None:
34
37
  hook_output = json.loads(_run_hook(_payload("Artifact", {"action": "quickstart", "intent": "other"})))
35
38
  specific_output = hook_output["hookSpecificOutput"]
36
39
  assert specific_output["hookEventName"] == "PreToolUse"
37
- assert str(ARTIFACT_TEMPLATE_PATH) in specific_output["additionalContext"]
40
+ pointer_text = specific_output["additionalContext"]
41
+ assert pointer_text.index(str(HTML_PLAN_TEMPLATE_README_PATH)) < pointer_text.index(
42
+ str(ARTIFACT_TEMPLATE_PATH)
43
+ )
38
44
  assert "permissionDecision" not in specific_output
39
45
 
40
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.69.1",
3
+ "version": "8.71.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
  )