claude-dev-env 8.67.0 → 8.68.1

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.
@@ -12,7 +12,7 @@ What counts as proof:
12
12
  - You ran the changed thing the way its user runs it, and you saw the effect it exists to produce. A hook fires in a live Claude Code session and changes what that session does. A script runs from its launcher on inputs like the ones it will meet. A skill or rule changes what a fresh agent does with an ordinary request.
13
13
  - You saw the failure first. Run the same scenario without your change and record what goes wrong. The proof is the difference between the two runs.
14
14
  - You covered the conditions the change will meet. That includes the case it should leave alone, and for session behavior a fresh session, a resumed session, a later turn, and a session after compaction.
15
- - The session under test got an ordinary request, worded as a task the way Jon would ask for it.
15
+ - The session under test got an ordinary request, worded as a task the way the owner would ask for it.
16
16
 
17
17
  Unit tests are welcome as extra evidence. The live run is the proof. A test you edited until it passed proves nothing.
18
18
 
@@ -28,10 +28,12 @@ For a change to Claude Code behavior, such as a hook, skill, rule, setting, prom
28
28
 
29
29
  Report it in the pull request body under "Proof in practice". List each command you ran, quote the output lines that show the effect, state the difference between the runs with and without the change, and name anything still unproven.
30
30
 
31
- When you cannot prove it, leave the pull request unopened. Tell Jon in three short sentences what you could not run and which tool would make it possible. A blocked proof is an acceptable result, because it shows where tooling is missing.
31
+ When you cannot prove it, leave the pull request unopened. Tell the owner in three short sentences what you could not run and which tool would make it possible. A blocked proof is an acceptable result, because it shows where tooling is missing.
32
32
 
33
33
  ## Enforcement
34
34
 
35
35
  `hooks/blocking/pull_request_proof.py` reads the body of each new pull request before it opens. `pr_lifecycle_skill_gate.py` runs it on `gh pr create`, the `pull_request.py create` script, and each `mcp__*__create_pull_request` tool. The gate denies the call when the body has no "Proof in practice" heading, or when the text under that heading names no command in backticks. A `gh pr create` with no `--body` or `--body-file` is denied with a request for the body. Edits, merges, and pushes pass.
36
36
 
37
+ When the checked-out branch changes a file people see (`.html`, `.htm`, `.css`, `.scss`, `.tsx`, `.jsx`, `.vue`, `.svelte`), the gate also denies a body whose proof section holds no picture: a markdown image, an image URL, or an Artifact page link that shows the screenshots. Render the change, read the screenshot, and put it in the section, so the reader sees what you looked at. When git in the session directory cannot list the branch's changed files, the gate asks for the picture as well.
38
+
37
39
  The gate checks that the section is there and names a command. Whether the run proves the change stays a judgment for the author and the reviewer.
@@ -1,5 +1,16 @@
1
1
  # Artifact page template
2
2
 
3
+ Two templates live here. Pick the one that fits the ask before you write a line.
4
+
5
+ | Ask | Template |
6
+ |---|---|
7
+ | A tracker, board, review page or dashboard that people read and click | `template.html` |
8
+ | A picture of how things connect: a map, a flow, who calls what, old next to new. Also any ask for "only visuals" or "no words" | `diagram.html` |
9
+
10
+ `diagram.html` draws the content in inline SVG: boxes for things, arrows for links, and color for state. Labels are a few words. Text in bordered cards is a tracker, so it never answers a visual-only ask.
11
+
12
+ Render the page and look at the screenshot before you publish. Headless Chromium renders at least 500 pixels wide, so take the screenshot at 800 wide.
13
+
3
14
  `template.html` is the reference page for any artifact an agent builds: a tracker, a board, a review page, a dashboard. Start from it, keep its structure and house style, and replace the sample data and the parts the new page does not need.
4
15
 
5
16
  ## What it demonstrates
@@ -0,0 +1,50 @@
1
+ <title>Flow Diagram</title>
2
+ <style>
3
+ :root {
4
+ --bg: #f4f5f7; --surface: #ffffff; --fg: #1b2230; --line: #6b7383;
5
+ --ok: #165737; --ok-soft: #e3f3ea; --bad: #8b2621; --bad-soft: #fbe5e3;
6
+ --font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
7
+ }
8
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
9
+ --bg: #12151b; --surface: #1a1f28; --fg: #e6e9ef; --line: #8a94a8;
10
+ --ok: #6fd3a0; --ok-soft: #173327; --bad: #ffa8a0; --bad-soft: #3a1c1a;
11
+ color-scheme: dark } }
12
+ :root[data-theme="dark"] {
13
+ --bg: #12151b; --surface: #1a1f28; --fg: #e6e9ef; --line: #8a94a8;
14
+ --ok: #6fd3a0; --ok-soft: #173327; --bad: #ffa8a0; --bad-soft: #3a1c1a;
15
+ color-scheme: dark }
16
+ * { box-sizing: border-box }
17
+ body { background: var(--bg); color: var(--fg); font-family: var(--font-body); margin: 0; padding: 16px }
18
+ svg { display: block; width: 100%; max-width: 960px; height: auto; margin: 0 auto }
19
+ .node rect { stroke-width: 3 }
20
+ .node.ok rect { fill: var(--ok-soft); stroke: var(--ok) }
21
+ .node.bad rect { fill: var(--bad-soft); stroke: var(--bad) }
22
+ .node.caller rect { fill: var(--surface); stroke: var(--line) }
23
+ .node text { fill: var(--fg); font-size: 15px; font-weight: 700; text-anchor: middle; dominant-baseline: middle }
24
+ .edge { fill: none; stroke-width: 3 }
25
+ .edge.ok { stroke: var(--ok) }
26
+ .edge.bad { stroke: var(--bad) }
27
+ .key text { fill: var(--fg); font-size: 14px; font-weight: 700; dominant-baseline: middle }
28
+ </style>
29
+ <svg viewBox="0 0 720 360" role="img" aria-label="Callers on the left point at the source they use on the right">
30
+ <defs>
31
+ <marker id="arrow-ok" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="var(--ok)"/></marker>
32
+ <marker id="arrow-bad" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="var(--bad)"/></marker>
33
+ </defs>
34
+
35
+ <g class="key" transform="translate(20 24)">
36
+ <rect width="18" height="18" rx="4" fill="var(--ok)"/><text x="26" y="9">current</text>
37
+ <rect x="120" width="18" height="18" rx="4" fill="var(--bad)"/><text x="146" y="9">retire</text>
38
+ </g>
39
+
40
+ <g class="node caller"><rect x="20" y="80" width="200" height="56" rx="12"/><text x="120" y="108">Caller A</text></g>
41
+ <g class="node caller"><rect x="20" y="180" width="200" height="56" rx="12"/><text x="120" y="208">Caller B</text></g>
42
+ <g class="node caller"><rect x="20" y="280" width="200" height="56" rx="12"/><text x="120" y="308">Caller C</text></g>
43
+
44
+ <g class="node ok"><rect x="480" y="110" width="220" height="64" rx="12"/><text x="590" y="142">Source one</text></g>
45
+ <g class="node bad"><rect x="480" y="250" width="220" height="64" rx="12"/><text x="590" y="282">Source two</text></g>
46
+
47
+ <path class="edge ok" d="M220 108 C350 108 350 142 478 142" marker-end="url(#arrow-ok)"/>
48
+ <path class="edge bad" d="M220 208 C350 208 350 282 478 282" marker-end="url(#arrow-bad)"/>
49
+ <path class="edge bad" d="M220 308 C350 308 350 282 478 282" marker-end="url(#arrow-bad)"/>
50
+ </svg>
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: refuse to publish an artifact page nobody has looked at.
3
+
4
+ An agent that writes a page and publishes it without rendering it cannot know
5
+ the page matches the ask. The hook walks the session transcript and allows the
6
+ publish only when a Read opened an image file whose modification time is at
7
+ least the page file's::
8
+
9
+ Write page.html -> shoot shot.png -> Read shot.png -> publish -> allowed
10
+ Write page.html -> publish -> refused
11
+ Read shot.png -> change page.html in any tool -> publish -> refused
12
+ Read an image older than the page -> publish -> refused
13
+
14
+ A Read that returned an error, or whose result came before the page's last
15
+ change, does not count. A non-HTML file, an asset
16
+ upload, a non-publish action and an unreadable transcript all pass, so the
17
+ hook never fails closed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
28
+ if hooks_root_directory not in sys.path:
29
+ sys.path.insert(0, hooks_root_directory)
30
+
31
+ from hooks_constants.artifact_look_gate_constants import (
32
+ ALL_IMAGE_FILE_SUFFIXES,
33
+ ALL_PAGE_FILE_SUFFIXES,
34
+ ARTIFACT_ACTION_INPUT_KEY,
35
+ ARTIFACT_ASSET_INPUT_KEY,
36
+ ARTIFACT_PUBLISH_ACTION,
37
+ ARTIFACT_TOOL_NAME,
38
+ ASSISTANT_ENTRY_TYPE,
39
+ DENY_DECISION,
40
+ ENTRY_TIMESTAMP_KEY,
41
+ FILE_PATH_INPUT_KEY,
42
+ IMAGE_READING_TOOL_NAME,
43
+ PRE_TOOL_USE_EVENT_NAME,
44
+ TOOL_INPUT_KEY,
45
+ TOOL_NAME_KEY,
46
+ TOOL_RESULT_BLOCK_TYPE,
47
+ TOOL_USE_BLOCK_TYPE,
48
+ TRANSCRIPT_DECODE_ERRORS,
49
+ TRANSCRIPT_ENCODING,
50
+ TRANSCRIPT_PATH_KEY,
51
+ UNSEEN_PAGE_REASON_TEMPLATE,
52
+ USER_ENTRY_TYPE,
53
+ )
54
+ from hooks_constants.hook_specific_output_keys import (
55
+ HOOK_EVENT_NAME_KEY,
56
+ HOOK_SPECIFIC_OUTPUT_KEY,
57
+ PERMISSION_DECISION_KEY,
58
+ )
59
+ from hooks_constants.pr_lifecycle_skill_gate_constants import (
60
+ PERMISSION_DECISION_REASON_KEY,
61
+ )
62
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
63
+
64
+
65
+ def _content_blocks(
66
+ all_entry_fields: dict[str, object], entry_type: str
67
+ ) -> list[dict[str, object]]:
68
+ if all_entry_fields.get("type") != entry_type:
69
+ return []
70
+ message = all_entry_fields.get("message")
71
+ content = message.get("content") if isinstance(message, dict) else None
72
+ if not isinstance(content, list):
73
+ return []
74
+ return [each_block for each_block in content if isinstance(each_block, dict)]
75
+
76
+
77
+ def _parsed_entry(transcript_line: str) -> dict[str, object]:
78
+ try:
79
+ parsed_line = json.loads(transcript_line)
80
+ except json.JSONDecodeError:
81
+ return {}
82
+ return parsed_line if isinstance(parsed_line, dict) else {}
83
+
84
+
85
+ def _entry_epoch(all_entry_fields: dict[str, object]) -> float | None:
86
+ timestamp_text = all_entry_fields.get(ENTRY_TIMESTAMP_KEY)
87
+ if not isinstance(timestamp_text, str):
88
+ return None
89
+ try:
90
+ return datetime.fromisoformat(timestamp_text.replace("Z", "+00:00")).timestamp()
91
+ except ValueError:
92
+ return None
93
+
94
+
95
+ def _successful_result_epochs(all_entries: list[dict[str, object]]) -> dict[str, float]:
96
+ return {
97
+ str(each_block.get("tool_use_id")): result_epoch
98
+ for each_entry in all_entries
99
+ if (result_epoch := _entry_epoch(each_entry)) is not None
100
+ for each_block in _content_blocks(each_entry, USER_ENTRY_TYPE)
101
+ if each_block.get("type") == TOOL_RESULT_BLOCK_TYPE and not each_block.get("is_error")
102
+ }
103
+
104
+
105
+ def _tool_calls(all_entries: list[dict[str, object]]) -> list[tuple[str, object, object]]:
106
+ return [
107
+ (str(each_block.get("id")), each_block.get("name"), tool_input.get(FILE_PATH_INPUT_KEY))
108
+ for each_entry in all_entries
109
+ for each_block in _content_blocks(each_entry, ASSISTANT_ENTRY_TYPE)
110
+ if each_block.get("type") == TOOL_USE_BLOCK_TYPE
111
+ and isinstance(tool_input := each_block.get("input"), dict)
112
+ ]
113
+
114
+
115
+ def _is_image_read(tool_name: object, file_path: object) -> bool:
116
+ return (
117
+ tool_name == IMAGE_READING_TOOL_NAME
118
+ and isinstance(file_path, str)
119
+ and file_path.lower().endswith(ALL_IMAGE_FILE_SUFFIXES)
120
+ )
121
+
122
+
123
+ def _modified_time(file_path: str) -> float | None:
124
+ try:
125
+ return Path(file_path).stat().st_mtime
126
+ except OSError:
127
+ return None
128
+
129
+
130
+ def page_was_looked_at(all_transcript_lines: list[str], page_path: str) -> bool:
131
+ """Return True when a screenshot made after the page's last change was read.
132
+
133
+ The page's modification time covers every way it can change, including a
134
+ Bash command or a subagent. A Read counts only when it succeeded, its
135
+ result arrived after the page last changed, and its image file is at
136
+ least as new as the page.
137
+
138
+ Args:
139
+ all_transcript_lines: The session transcript, one JSON record per line.
140
+ page_path: The file the Artifact call publishes.
141
+ """
142
+ page_modified_time = _modified_time(page_path)
143
+ if page_modified_time is None:
144
+ return False
145
+ all_entries = [_parsed_entry(each_line) for each_line in all_transcript_lines]
146
+ result_epoch_by_id = _successful_result_epochs(all_entries)
147
+ for each_id, each_tool_name, each_file_path in _tool_calls(all_entries):
148
+ result_epoch = result_epoch_by_id.get(each_id)
149
+ if not _is_image_read(each_tool_name, each_file_path) or result_epoch is None:
150
+ continue
151
+ image_modified_time = _modified_time(str(each_file_path))
152
+ if (
153
+ image_modified_time is not None
154
+ and image_modified_time >= page_modified_time
155
+ and result_epoch >= page_modified_time
156
+ ):
157
+ return True
158
+ return False
159
+
160
+
161
+ def _published_page_path(all_hook_fields: dict[str, object]) -> str | None:
162
+ tool_input = all_hook_fields.get(TOOL_INPUT_KEY)
163
+ if all_hook_fields.get(TOOL_NAME_KEY) != ARTIFACT_TOOL_NAME or not isinstance(tool_input, dict):
164
+ return None
165
+ page_path = tool_input.get(FILE_PATH_INPUT_KEY)
166
+ is_page_publish = (
167
+ tool_input.get(ARTIFACT_ACTION_INPUT_KEY, ARTIFACT_PUBLISH_ACTION) == ARTIFACT_PUBLISH_ACTION
168
+ and tool_input.get(ARTIFACT_ASSET_INPUT_KEY) is not True
169
+ and isinstance(page_path, str)
170
+ and page_path.lower().endswith(ALL_PAGE_FILE_SUFFIXES)
171
+ )
172
+ return page_path if is_page_publish else None
173
+
174
+
175
+ def unseen_page_reason(all_hook_fields: dict[str, object]) -> str | None:
176
+ """Return the refusal for an unseen page publish, or None to allow it.
177
+
178
+ Args:
179
+ all_hook_fields: The parsed PreToolUse payload.
180
+ """
181
+ page_path = _published_page_path(all_hook_fields)
182
+ transcript_path = all_hook_fields.get(TRANSCRIPT_PATH_KEY)
183
+ if page_path is None or not isinstance(transcript_path, str):
184
+ return None
185
+ try:
186
+ all_transcript_lines = Path(transcript_path).read_text(
187
+ encoding=TRANSCRIPT_ENCODING, errors=TRANSCRIPT_DECODE_ERRORS
188
+ ).splitlines()
189
+ except OSError:
190
+ return None
191
+ if page_was_looked_at(all_transcript_lines, page_path):
192
+ return None
193
+ return UNSEEN_PAGE_REASON_TEMPLATE.format(page_path=page_path)
194
+
195
+
196
+ def main() -> int:
197
+ """Read the PreToolUse payload and print a deny decision for an unseen page.
198
+
199
+ Returns:
200
+ 0 in every case; the decision travels in the JSON output.
201
+ """
202
+ hook_payload = read_hook_input_dictionary_from_stdin()
203
+ if hook_payload is None:
204
+ return 0
205
+ refusal_reason = unseen_page_reason(hook_payload)
206
+ if refusal_reason is not None:
207
+ sys.stdout.write(
208
+ json.dumps(
209
+ {
210
+ HOOK_SPECIFIC_OUTPUT_KEY: {
211
+ HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
212
+ PERMISSION_DECISION_KEY: DENY_DECISION,
213
+ PERMISSION_DECISION_REASON_KEY: refusal_reason,
214
+ }
215
+ }
216
+ )
217
+ )
218
+ sys.stdout.flush()
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
@@ -53,7 +53,11 @@ from hooks_constants.hook_specific_output_keys import (
53
53
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
54
54
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
55
55
  from blocking.followup_pr_dedupe import duplicate_followup_reason
56
- from blocking.pull_request_proof import missing_existing_work_reason, missing_proof_reason
56
+ from blocking.pull_request_proof import (
57
+ missing_existing_work_reason,
58
+ missing_look_reason,
59
+ missing_proof_reason,
60
+ )
57
61
  from transcript_skill_scan import is_skill_loaded_after_last_compaction
58
62
 
59
63
 
@@ -214,6 +218,9 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
214
218
  search_reason = missing_existing_work_reason(all_payload_fields)
215
219
  if search_reason is not None:
216
220
  return _deny(search_reason)
221
+ look_reason = missing_look_reason(all_payload_fields)
222
+ if look_reason is not None:
223
+ return _deny(look_reason)
217
224
  duplicate_reason = duplicate_followup_reason(all_payload_fields)
218
225
  return None if duplicate_reason is None else _deny(duplicate_reason)
219
226
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ import subprocess
6
7
  import sys
7
8
  from collections.abc import Mapping
8
9
  from pathlib import Path
@@ -16,15 +17,25 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
16
17
  from hooks_constants.pull_request_proof_constants import (
17
18
  ALL_GH_CREATE_WORDS,
18
19
  ALL_PYTHON_PROGRAM_NAMES,
20
+ ALL_VISIBLE_FILE_SUFFIXES,
21
+ CHANGED_FILES_SEPARATOR,
19
22
  COMMAND_MARKER,
20
23
  CREATE_PULL_REQUEST_TOOL_SUFFIX,
24
+ ALL_DEFAULT_BRANCH_REFERENCES,
25
+ ALL_HEAD_FLAGS,
26
+ HEAD_FLAG_ASSIGNMENT_PREFIX,
27
+ REMOTE_BRANCH_PREFIX,
28
+ UNREADABLE_CHANGES_REASON,
21
29
  EXISTING_WORK_HEADING_PATTERN,
30
+ GIT_TIMEOUT_SECONDS,
22
31
  LINE_SEPARATOR,
23
32
  MISSING_BODY_REASON,
24
33
  MISSING_EXISTING_WORK_REASON,
34
+ MISSING_LOOK_REASON,
25
35
  MISSING_PROOF_REASON,
26
36
  NEXT_HEADING_PATTERN,
27
37
  PROOF_HEADING_PATTERN,
38
+ PROOF_IMAGE_PATTERN,
28
39
  PULL_REQUEST_SCRIPT_CREATE_WORD,
29
40
  PULL_REQUEST_SCRIPT_NAME,
30
41
  )
@@ -118,6 +129,34 @@ def _shell_create_bodies(command: str, working_directory: str) -> list[str | Non
118
129
  ]
119
130
 
120
131
 
132
+ def _head_flag_value(all_create_arguments: list[str]) -> str | None:
133
+ for each_position, each_argument in enumerate(all_create_arguments):
134
+ if each_argument in ALL_HEAD_FLAGS and each_position + 1 < len(all_create_arguments):
135
+ return all_create_arguments[each_position + 1]
136
+ if each_argument.startswith(HEAD_FLAG_ASSIGNMENT_PREFIX):
137
+ return each_argument[len(HEAD_FLAG_ASSIGNMENT_PREFIX) :]
138
+ return None
139
+
140
+
141
+ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
142
+ tool_input = all_payload_fields.get("tool_input")
143
+ if not isinstance(tool_input, dict):
144
+ return None
145
+ head_branch = tool_input.get("head")
146
+ if isinstance(head_branch, str):
147
+ return head_branch
148
+ command = tool_input.get("command")
149
+ if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
150
+ return None
151
+ all_head_flag_values = [
152
+ _head_flag_value(all_create_arguments)
153
+ for each_text in all_wrapped_command_texts(command)
154
+ for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
155
+ if (all_create_arguments := _create_arguments(each_segment)) is not None
156
+ ]
157
+ return next((each_value for each_value in all_head_flag_values if each_value), None)
158
+
159
+
121
160
  def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
122
161
  tool_name = all_payload_fields.get("tool_name")
123
162
  tool_input = all_payload_fields.get("tool_input")
@@ -181,3 +220,96 @@ def missing_existing_work_reason(all_payload_fields: Mapping[str, object]) -> st
181
220
  if each_body is not None and not section_under_existing_work(each_body):
182
221
  return MISSING_EXISTING_WORK_REASON
183
222
  return None
223
+
224
+
225
+ def _git_lines(working_directory: str, *all_git_arguments: str) -> list[str] | None:
226
+ try:
227
+ completed = subprocess.run(
228
+ ["git", "-C", working_directory, *all_git_arguments],
229
+ capture_output=True,
230
+ text=True,
231
+ timeout=GIT_TIMEOUT_SECONDS,
232
+ check=False,
233
+ )
234
+ except (OSError, subprocess.SubprocessError):
235
+ return None
236
+ return completed.stdout.splitlines() if completed.returncode == 0 else None
237
+
238
+
239
+ def _first_merge_base(working_directory: str, head_reference: str) -> str | None:
240
+ for each_base_reference in ALL_DEFAULT_BRANCH_REFERENCES:
241
+ all_merge_base_lines = _git_lines(working_directory, "merge-base", head_reference, each_base_reference)
242
+ if all_merge_base_lines:
243
+ return all_merge_base_lines[0]
244
+ return None
245
+
246
+
247
+ def _head_reference(working_directory: str, head_branch: object) -> str | None:
248
+ if not isinstance(head_branch, str) or not head_branch:
249
+ return "HEAD"
250
+ if _git_lines(working_directory, "branch", "--show-current") == [head_branch]:
251
+ return "HEAD"
252
+ for each_candidate in (head_branch, REMOTE_BRANCH_PREFIX + head_branch):
253
+ if _git_lines(working_directory, "rev-parse", "--verify", "--quiet", each_candidate + "^{commit}"):
254
+ return each_candidate
255
+ return None
256
+
257
+
258
+ def visible_changed_files(working_directory: str, head_branch: object) -> list[str] | None:
259
+ """Return the changed files people see on the head branch, or None when git cannot tell.
260
+
261
+ The head is the checked-out branch, or the named branch or its origin copy
262
+ when the checkout holds another branch. The base is the first of
263
+ origin/HEAD, origin/main and main that git can reach::
264
+
265
+ checkout on feature, page.html changed -> ["page.html"]
266
+ named head exists nowhere in the clone -> None
267
+ no base reference reachable -> None
268
+
269
+ Args:
270
+ working_directory: The session directory the hook payload names.
271
+ head_branch: The head branch a create call names, or None.
272
+ """
273
+ if not working_directory:
274
+ return None
275
+ head_reference = _head_reference(working_directory, head_branch)
276
+ if head_reference is None:
277
+ return None
278
+ merge_base = _first_merge_base(working_directory, head_reference)
279
+ if merge_base is None:
280
+ return None
281
+ all_changed_paths = _git_lines(working_directory, "diff", "--name-only", merge_base, head_reference)
282
+ if all_changed_paths is None:
283
+ return None
284
+ return [each_path for each_path in all_changed_paths if each_path.lower().endswith(ALL_VISIBLE_FILE_SUFFIXES)]
285
+
286
+
287
+ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
288
+ """Return a deny reason when a pull request changing visible files shows no picture, else None.
289
+
290
+ ::
291
+
292
+ branch changes page.html, proof has ![after](https://x/after.png) -> None
293
+ branch changes page.html, proof quotes only command output -> MISSING_LOOK_REASON
294
+ branch changes only .py files -> None
295
+ git cannot read the branch, proof shows no picture -> UNREADABLE_CHANGES_REASON
296
+
297
+ Args:
298
+ all_payload_fields: The parsed PreToolUse input.
299
+ """
300
+ all_bodies = [each_body for each_body in _create_bodies(all_payload_fields) if each_body is not None]
301
+ if not all_bodies:
302
+ return None
303
+ working_directory = all_payload_fields.get("cwd")
304
+ all_visible_files = visible_changed_files(
305
+ str(working_directory or ""), _named_head_branch(all_payload_fields)
306
+ )
307
+ if all_visible_files == []:
308
+ return None
309
+ for each_body in all_bodies:
310
+ if re.search(PROOF_IMAGE_PATTERN, proof_section(each_body) or "", re.IGNORECASE):
311
+ continue
312
+ if all_visible_files is None:
313
+ return UNREADABLE_CHANGES_REASON
314
+ return MISSING_LOOK_REASON.format(changed_files=CHANGED_FILES_SEPARATOR.join(all_visible_files))
315
+ return None
@@ -0,0 +1,152 @@
1
+ """Behavior tests for the artifact look gate.
2
+
3
+ The refused case replays a session that wrote a page and published it with no
4
+ render in between, the way a text-card page reached the person who asked for
5
+ a diagram.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _BLOCKING_DIR = Path(__file__).resolve().parent
15
+ _HOOKS_ROOT = _BLOCKING_DIR.parent
16
+ for each_sys_path_entry in (str(_BLOCKING_DIR), str(_HOOKS_ROOT)):
17
+ if each_sys_path_entry not in sys.path:
18
+ sys.path.insert(0, each_sys_path_entry)
19
+
20
+ import artifact_look_gate
21
+
22
+ HOOK_SCRIPT = _BLOCKING_DIR / "artifact_look_gate.py"
23
+
24
+
25
+ def _tool_use(call_id: str, tool_name: str, file_path: str) -> dict[str, object]:
26
+ return {
27
+ "type": "assistant",
28
+ "message": {
29
+ "content": [
30
+ {
31
+ "type": "tool_use",
32
+ "id": call_id,
33
+ "name": tool_name,
34
+ "input": {"file_path": file_path},
35
+ }
36
+ ]
37
+ },
38
+ }
39
+
40
+
41
+ def _tool_result(
42
+ call_id: str, is_error: bool = False, timestamp: str = "2026-01-01T00:00:00Z"
43
+ ) -> dict[str, object]:
44
+ return {
45
+ "type": "user",
46
+ "timestamp": timestamp,
47
+ "message": {
48
+ "content": [
49
+ {
50
+ "type": "tool_result",
51
+ "tool_use_id": call_id,
52
+ "content": "ok",
53
+ "is_error": is_error,
54
+ }
55
+ ]
56
+ },
57
+ }
58
+
59
+
60
+ def _run_hook(tmp_path: Path, all_records: list[dict[str, object]], page_path: Path) -> str:
61
+ transcript_path = tmp_path / "transcript.jsonl"
62
+ transcript_path.write_text(
63
+ "".join(json.dumps(each_record) + "\n" for each_record in all_records), encoding="utf-8"
64
+ )
65
+ completed = subprocess.run(
66
+ [sys.executable, str(HOOK_SCRIPT)],
67
+ input=json.dumps(
68
+ {
69
+ "hook_event_name": "PreToolUse",
70
+ "tool_name": "Artifact",
71
+ "tool_input": {"file_path": str(page_path), "icon": "map"},
72
+ "transcript_path": str(transcript_path),
73
+ }
74
+ ),
75
+ capture_output=True,
76
+ text=True,
77
+ check=True,
78
+ )
79
+ return completed.stdout
80
+
81
+
82
+ def _file_at(file_path: Path, modified_time: float) -> Path:
83
+ file_path.write_text("x", encoding="utf-8")
84
+ os.utime(file_path, (modified_time, modified_time))
85
+ return file_path
86
+
87
+
88
+ def _read_records(
89
+ image_path: Path, is_error: bool = False, timestamp: str = "2026-01-01T00:00:00Z"
90
+ ) -> list[dict[str, object]]:
91
+ return [_tool_use("r1", "Read", str(image_path)), _tool_result("r1", is_error, timestamp)]
92
+
93
+
94
+ def test_should_refuse_a_page_published_without_a_look(tmp_path: Path) -> None:
95
+ page_path = _file_at(tmp_path / "page.html", 1000)
96
+ hook_output = json.loads(
97
+ _run_hook(
98
+ tmp_path, [_tool_use("w1", "Write", str(page_path)), _tool_result("w1")], page_path
99
+ )
100
+ )
101
+ specific_output = hook_output["hookSpecificOutput"]
102
+ assert specific_output["permissionDecision"] == "deny"
103
+ assert "--screenshot" in specific_output["permissionDecisionReason"]
104
+
105
+
106
+ def test_should_allow_a_page_whose_newer_screenshot_was_read(tmp_path: Path) -> None:
107
+ page_path = _file_at(tmp_path / "page.html", 1000)
108
+ image_path = _file_at(tmp_path / "page.png", 1001)
109
+ assert _run_hook(tmp_path, _read_records(image_path), page_path) == ""
110
+
111
+
112
+ def test_should_refuse_when_the_page_changed_after_the_screenshot(tmp_path: Path) -> None:
113
+ image_path = _file_at(tmp_path / "page.png", 1000)
114
+ page_path = _file_at(tmp_path / "page.html", 1001)
115
+ assert "deny" in _run_hook(tmp_path, _read_records(image_path), page_path)
116
+
117
+
118
+ def test_should_refuse_when_the_screenshot_read_failed(tmp_path: Path) -> None:
119
+ page_path = _file_at(tmp_path / "page.html", 1000)
120
+ image_path = _file_at(tmp_path / "page.png", 1001)
121
+ assert "deny" in _run_hook(tmp_path, _read_records(image_path, is_error=True), page_path)
122
+
123
+
124
+ def test_should_refuse_a_screenshot_overwritten_after_its_read(tmp_path: Path) -> None:
125
+ page_path = _file_at(tmp_path / "page.html", 1000)
126
+ image_path = _file_at(tmp_path / "page.png", 1001)
127
+ read_before_the_page_changed = "1970-01-01T00:16:39Z"
128
+ assert "deny" in _run_hook(
129
+ tmp_path, _read_records(image_path, timestamp=read_before_the_page_changed), page_path
130
+ )
131
+
132
+
133
+ def test_page_was_looked_at_ignores_reads_of_other_files(tmp_path: Path) -> None:
134
+ page_path = _file_at(tmp_path / "page.html", 1000)
135
+ notes_path = _file_at(tmp_path / "notes.md", 1001)
136
+ all_lines = [json.dumps(each_record) for each_record in _read_records(notes_path)]
137
+ assert not artifact_look_gate.page_was_looked_at(all_lines, str(page_path))
138
+
139
+
140
+ def test_unseen_page_reason_passes_non_publish_calls() -> None:
141
+ assert (
142
+ artifact_look_gate.unseen_page_reason(
143
+ {"tool_name": "Artifact", "tool_input": {"action": "read", "url": "x"}}
144
+ )
145
+ is None
146
+ )
147
+ assert (
148
+ artifact_look_gate.unseen_page_reason(
149
+ {"tool_name": "Artifact", "tool_input": {"file_path": "image.png", "asset": True}}
150
+ )
151
+ is None
152
+ )
@@ -1,6 +1,7 @@
1
1
  """Behavior tests for the pull request lifecycle PreToolUse gate."""
2
2
 
3
3
  import json
4
+ import subprocess
4
5
  import sys
5
6
  from io import BytesIO, StringIO, TextIOWrapper
6
7
  from pathlib import Path
@@ -240,11 +241,24 @@ def test_hook_has_its_own_pre_tool_use_registration() -> None:
240
241
  ]
241
242
 
242
243
 
244
+ def _checkout_without_visible_changes(tmp_path: Path) -> str:
245
+ checkout_path = tmp_path / "checkout"
246
+ subprocess.run(["git", "init", "-q", "-b", "main", str(checkout_path)], check=True)
247
+ (checkout_path / "tool.py").write_text("x = 1\n", encoding="utf-8")
248
+ subprocess.run(["git", "-C", str(checkout_path), "add", "."], check=True)
249
+ subprocess.run(
250
+ ["git", "-C", str(checkout_path), "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "base"],
251
+ check=True,
252
+ )
253
+ return str(checkout_path)
254
+
255
+
243
256
  def test_loaded_skill_still_denies_a_second_followup_for_one_parent(tmp_path: Path) -> None:
244
257
  payload = {
245
258
  "tool_name": "mcp__github__create_pull_request",
246
259
  "tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": PROVEN_FOLLOWUP_BODY},
247
260
  "transcript_path": str(_transcript(tmp_path, "pr-lifecycle")),
261
+ "cwd": _checkout_without_visible_changes(tmp_path),
248
262
  }
249
263
  open_followup = {"number": 1769, "html_url": "https://github.com/jl-cmd/claude-dev-env/pull/1769", "body": "Follow-up to #1731"}
250
264
  with patch.object(gate_dedupe, "read_open_pull_requests", return_value=[open_followup]):
@@ -259,6 +273,7 @@ def test_loaded_skill_allows_a_followup_when_the_read_fails(tmp_path: Path) -> N
259
273
  "tool_name": "mcp__github__create_pull_request",
260
274
  "tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": PROVEN_FOLLOWUP_BODY},
261
275
  "transcript_path": str(_transcript(tmp_path, "pr-lifecycle")),
276
+ "cwd": _checkout_without_visible_changes(tmp_path),
262
277
  }
263
278
  with patch.object(gate_dedupe, "read_open_pull_requests", side_effect=OSError("offline")):
264
279
  assert gate.decision_for(payload) is None
@@ -1,5 +1,6 @@
1
1
  """Behavior tests for the proof-in-practice and existing-work checks on a new pull request."""
2
2
 
3
+ import subprocess
3
4
  import sys
4
5
  from pathlib import Path
5
6
 
@@ -12,6 +13,7 @@ from hooks_constants.pull_request_proof_constants import (
12
13
  MISSING_BODY_REASON,
13
14
  MISSING_EXISTING_WORK_REASON,
14
15
  MISSING_PROOF_REASON,
16
+ UNREADABLE_CHANGES_REASON,
15
17
  )
16
18
 
17
19
  PROVEN_BODY = "## Summary\nAdds a gate.\n\n## Proof in practice\nRan `python run.py`.\n> denied\n"
@@ -133,3 +135,78 @@ def test_unreadable_body_should_leave_the_deny_to_the_proof_check() -> None:
133
135
  def test_other_pull_request_commands_should_pass_the_existing_work_check() -> None:
134
136
  for each_command in ("gh pr edit 3 --body x", "git push -u origin b", "gh pr view"):
135
137
  assert gate.missing_existing_work_reason(_shell(each_command)) is None
138
+
139
+
140
+ def _repository_with_change(tmp_path: Path, changed_file_name: str) -> Path:
141
+ origin_path = tmp_path / "origin"
142
+ clone_path = tmp_path / "clone"
143
+ git_identity = ["-c", "user.name=t", "-c", "user.email=t@example.com"]
144
+ subprocess.run(["git", "init", "-q", "-b", "main", str(origin_path)], check=True)
145
+ (origin_path / "readme.md").write_text("base\n", encoding="utf-8")
146
+ subprocess.run(["git", "-C", str(origin_path), "add", "."], check=True)
147
+ subprocess.run(["git", "-C", str(origin_path), *git_identity, "commit", "-q", "-m", "base"], check=True)
148
+ subprocess.run(["git", "clone", "-q", str(origin_path), str(clone_path)], check=True)
149
+ subprocess.run(["git", "-C", str(clone_path), "checkout", "-q", "-b", "feature"], check=True)
150
+ (clone_path / changed_file_name).write_text("<p>x</p>\n", encoding="utf-8")
151
+ subprocess.run(["git", "-C", str(clone_path), "add", "."], check=True)
152
+ subprocess.run(["git", "-C", str(clone_path), *git_identity, "commit", "-q", "-m", "change"], check=True)
153
+ return clone_path
154
+
155
+
156
+ def _mcp_create_on_branch(body: str, working_directory: Path) -> dict[str, object]:
157
+ return {
158
+ "tool_name": "mcp__github__create_pull_request",
159
+ "tool_input": {"body": body, "head": "feature"},
160
+ "cwd": str(working_directory),
161
+ }
162
+
163
+
164
+ def test_should_deny_a_page_change_whose_proof_shows_no_picture(tmp_path: Path) -> None:
165
+ clone_path = _repository_with_change(tmp_path, "page.html")
166
+ reason = gate.missing_look_reason(_mcp_create_on_branch(PROVEN_BODY, clone_path))
167
+ assert reason is not None
168
+ assert "page.html" in reason
169
+
170
+
171
+ def test_should_allow_a_page_change_whose_proof_links_a_screenshot(tmp_path: Path) -> None:
172
+ clone_path = _repository_with_change(tmp_path, "page.html")
173
+ body = PROVEN_BODY + "![after](https://example.com/after.png)\n"
174
+ assert gate.missing_look_reason(_mcp_create_on_branch(body, clone_path)) is None
175
+
176
+
177
+ def test_should_allow_a_python_only_change_without_a_picture(tmp_path: Path) -> None:
178
+ clone_path = _repository_with_change(tmp_path, "tool.py")
179
+ assert gate.missing_look_reason(_mcp_create_on_branch(PROVEN_BODY, clone_path)) is None
180
+
181
+
182
+ def test_visible_changed_files_skips_a_different_head_branch(tmp_path: Path) -> None:
183
+ clone_path = _repository_with_change(tmp_path, "page.html")
184
+ assert gate.visible_changed_files(str(clone_path), "feature") == ["page.html"]
185
+ assert gate.visible_changed_files(str(clone_path), "other-branch") is None
186
+
187
+
188
+ def test_should_deny_when_git_cannot_read_the_branch_and_proof_shows_no_picture(tmp_path: Path) -> None:
189
+ payload = _mcp_create_on_branch(PROVEN_BODY, tmp_path)
190
+ assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
191
+
192
+
193
+ def test_visible_changed_files_falls_back_to_origin_main_without_origin_head(tmp_path: Path) -> None:
194
+ clone_path = _repository_with_change(tmp_path, "page.html")
195
+ subprocess.run(["git", "-C", str(clone_path), "remote", "set-head", "origin", "-d"], check=True)
196
+ subprocess.run(["git", "-C", str(clone_path), "checkout", "-q", "main"], check=True)
197
+ assert gate.visible_changed_files(str(clone_path), "feature") == ["page.html"]
198
+
199
+
200
+ def test_should_read_the_head_flag_of_a_shell_create(tmp_path: Path) -> None:
201
+ clone_path = _repository_with_change(tmp_path, "page.html")
202
+ subprocess.run(["git", "-C", str(clone_path), "checkout", "-q", "main"], check=True)
203
+ body_path = clone_path / "body.md"
204
+ body_path.write_text(PROVEN_BODY, encoding="utf-8")
205
+ payload = {
206
+ "tool_name": "Bash",
207
+ "tool_input": {"command": f"gh pr create --head feature --title t --body-file {body_path}"},
208
+ "cwd": str(clone_path),
209
+ }
210
+ reason = gate.missing_look_reason(payload)
211
+ assert reason is not None
212
+ assert "page.html" in reason
@@ -12,6 +12,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
12
12
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
13
13
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
14
14
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
15
+ - `blocking/artifact_look_gate.py` denies an Artifact publish of an HTML page until an image was opened with Read after the page file was last written, so the agent looks at its page before it ships.
15
16
  - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
16
17
 
17
18
  ## When it fires
@@ -24,6 +25,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
24
25
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
25
26
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
26
27
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
28
+ - `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
27
29
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
28
30
 
29
31
  ## Proving it
@@ -50,4 +52,5 @@ Preconditions:
50
52
  - The edit marker gate scans replacement cards as well as the message text.
51
53
  - The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
52
54
  - The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
55
+ - **Looked-at page.** Input is a transcript that writes a page and publishes it with no screenshot read after the write. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_look_gate.py -q`. The adjacent test observes a denial that names the screenshot command; a Read of a PNG after the last write passes.
53
56
  - The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
@@ -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/template.html`.
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.
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
package/hooks/hooks.json CHANGED
@@ -169,6 +169,11 @@
169
169
  "type": "command",
170
170
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/artifact_template_pointer.py",
171
171
  "timeout": 10
172
+ },
173
+ {
174
+ "type": "command",
175
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_look_gate.py",
176
+ "timeout": 10
172
177
  }
173
178
  ]
174
179
  },
@@ -0,0 +1,33 @@
1
+ """Names and refusal text for the artifact_look_gate PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ARTIFACT_TOOL_NAME = "Artifact"
6
+ ARTIFACT_ACTION_INPUT_KEY = "action"
7
+ ARTIFACT_PUBLISH_ACTION = "publish"
8
+ ARTIFACT_ASSET_INPUT_KEY = "asset"
9
+ FILE_PATH_INPUT_KEY = "file_path"
10
+ TOOL_NAME_KEY = "tool_name"
11
+ TOOL_INPUT_KEY = "tool_input"
12
+ TRANSCRIPT_PATH_KEY = "transcript_path"
13
+ IMAGE_READING_TOOL_NAME = "Read"
14
+ ALL_IMAGE_FILE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".gif")
15
+ ALL_PAGE_FILE_SUFFIXES = (".html", ".htm")
16
+ TRANSCRIPT_ENCODING = "utf-8"
17
+ TRANSCRIPT_DECODE_ERRORS = "replace"
18
+ ASSISTANT_ENTRY_TYPE = "assistant"
19
+ USER_ENTRY_TYPE = "user"
20
+ TOOL_USE_BLOCK_TYPE = "tool_use"
21
+ TOOL_RESULT_BLOCK_TYPE = "tool_result"
22
+ ENTRY_TIMESTAMP_KEY = "timestamp"
23
+ PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
24
+ DENY_DECISION = "deny"
25
+ UNSEEN_PAGE_REASON_TEMPLATE = (
26
+ "This page has not been looked at since it was last written. Render it "
27
+ "and look at the picture before you publish: take a screenshot with a "
28
+ "headless browser, for example `chromium --headless --screenshot=page.png "
29
+ "--window-size=800,900 file://{page_path}`, then open page.png with the "
30
+ "Read tool and check it against the ask. Fix what the picture shows, take "
31
+ "a new screenshot, and publish again. Attach that screenshot when you "
32
+ "report the page, so the person who asked sees the same proof."
33
+ )
@@ -22,6 +22,6 @@ SKILL_TOOL_NAME = "Skill"
22
22
  SKILL_NAME_INPUT_KEY = "skill"
23
23
  ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
24
24
  ARTIFACT_TEMPLATE_PATH = (
25
- Path(__file__).resolve().parents[2] / "docs" / "templates" / "artifact-page" / "template.html"
25
+ Path(__file__).resolve().parents[2] / "docs" / "templates" / "artifact-page" / "README.md"
26
26
  )
27
- POINTER_TEXT_PREFIX = "Build this artifact page from the template at "
27
+ POINTER_TEXT_PREFIX = "Pick the artifact page template that fits the ask from "
@@ -39,3 +39,28 @@ MISSING_EXISTING_WORK_REASON = (
39
39
  + EXISTING_WORK_GUIDE_PATH
40
40
  + " for the full rule."
41
41
  )
42
+ ALL_VISIBLE_FILE_SUFFIXES = (".html", ".htm", ".css", ".scss", ".tsx", ".jsx", ".vue", ".svelte")
43
+ PROOF_IMAGE_PATTERN = (
44
+ r"!\[[^\]]*\]\([^)]+\)|https?://\S+?\.(?:png|jpe?g|gif|webp)\b"
45
+ r"|https://claude\.ai/(?:code/)?artifact/[A-Za-z0-9-]+"
46
+ )
47
+ ALL_DEFAULT_BRANCH_REFERENCES = ("origin/HEAD", "origin/main", "main")
48
+ REMOTE_BRANCH_PREFIX = "origin/"
49
+ ALL_HEAD_FLAGS = frozenset({"--head", "-H"})
50
+ HEAD_FLAG_ASSIGNMENT_PREFIX = "--head="
51
+ GIT_TIMEOUT_SECONDS = 5
52
+ MISSING_LOOK_REASON = (
53
+ "This pull request changes files people see ({changed_files}), and its 'Proof in"
54
+ " practice' section shows no picture. Render the change, open the screenshot with the"
55
+ " Read tool and check it against the ask, then put the screenshot in the section as an"
56
+ " image link, before and after when the change alters an existing view. Where the session"
57
+ " cannot upload an image, publish the screenshots as an Artifact page and link it. The person who"
58
+ " asked sees the same proof you looked at. Read " + PROOF_GUIDE_PATH + " for the full rule."
59
+ )
60
+ CHANGED_FILES_SEPARATOR = ", "
61
+ UNREADABLE_CHANGES_REASON = (
62
+ "Git in this directory cannot list the files this pull request changes, so the"
63
+ " picture check cannot tell whether people see the change. Run the create call"
64
+ " from a checkout of the head branch with origin fetched, or put a screenshot"
65
+ " link in the 'Proof in practice' section. Read " + PROOF_GUIDE_PATH + " for the full rule."
66
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.67.0",
3
+ "version": "8.68.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,6 +136,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
137
  hooks/blocking/session_title_stop_gate.py -> exempt
138
138
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
139
+ hooks/blocking/artifact_look_gate.py -> exempt
139
140
  hooks/blocking/some_new_blocker.py -> flagged
140
141
 
141
142
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -160,6 +161,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
160
161
  The verify-before-acting hook runs after a mutating call has finished. It
161
162
  asks the model to check a hedged claim, and the call it reads stays in place.
162
163
 
164
+ The artifact look gate denies an Artifact publish of a page until a
165
+ screenshot of it was read. It decides when a page ships and no code or
166
+ safety policy.
167
+
163
168
  The session title gates keep the session title in its status format. One
164
169
  denies a malformed title, and the other asks for a title before a turn
165
170
  ends. They decide the shape of a title and no code or safety policy.
@@ -72,6 +72,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
72
72
  "blocking/session_title_format_gate.py",
73
73
  "blocking/session_title_stop_gate.py",
74
74
  "blocking/artifact_dark_mode_gate.py",
75
+ "blocking/artifact_look_gate.py",
75
76
  )
76
77
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
77
78
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -19,6 +19,7 @@ a workflow runner.
19
19
  from __future__ import annotations
20
20
 
21
21
  import functools
22
+ import http.client
22
23
  import json
23
24
  import os
24
25
  import urllib.error
@@ -126,7 +127,12 @@ def request_json(
126
127
  try:
127
128
  with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as reply:
128
129
  return _decoded_answer(url, reply.status, reply.read())
129
- except (urllib.error.URLError, OSError, ValueError) as failure:
130
+ except (
131
+ urllib.error.URLError,
132
+ OSError,
133
+ ValueError,
134
+ http.client.HTTPException,
135
+ ) as failure:
130
136
  raise GitHubError(str(failure)) from failure
131
137
 
132
138
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import http.client
3
4
  import json
4
5
  import os
5
6
  import sys
@@ -379,3 +380,23 @@ def should_read_a_bot_comment_carrying_a_passed_marker_as_a_notice(
379
380
  )
380
381
 
381
382
  assert [each.is_notice for each in all_comments] == [True]
383
+
384
+
385
+ class TruncatedReply:
386
+ status = 200
387
+
388
+ def read(self) -> bytes:
389
+ raise http.client.IncompleteRead(b"{", 39041)
390
+
391
+
392
+ def should_report_a_reply_cut_short_as_a_github_error(
393
+ monkeypatch: pytest.MonkeyPatch,
394
+ ) -> None:
395
+ @contextmanager
396
+ def fake_urlopen(request: object, timeout: int = 0) -> Iterator[TruncatedReply]:
397
+ yield TruncatedReply()
398
+
399
+ monkeypatch.setattr(reader.urllib.request, "urlopen", fake_urlopen)
400
+
401
+ with pytest.raises(GitHubError, match="IncompleteRead"):
402
+ reader.read_pull_request("jl-cmd/claude-dev-env", 4347, TOKEN)