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.
- 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/docs/templates/html-plan/README.md +36 -0
- package/docs/templates/html-plan/house.css +26 -0
- package/hooks/blocking/image_read_size_gate.py +167 -0
- package/hooks/blocking/test_image_read_size_gate.py +79 -0
- package/hooks/features/README.md +1 -0
- package/hooks/features/read-gates.md +20 -0
- package/hooks/features/session-context.md +2 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +9 -3
- package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +9 -1
- package/hooks/session/artifact_template_pointer.py +10 -4
- package/hooks/session/test_artifact_template_pointer.py +8 -2
- package/package.json +1 -1
- package/scripts/agent_image_copy.py +155 -0
- package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- 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
|
+
)
|
package/hooks/features/README.md
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
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/
|
|
6
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
)
|