claude-dev-env 8.66.0 → 8.68.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/docs/rule-guides/proof-before-pull-request.md +4 -2
- package/docs/templates/artifact-page/README.md +11 -0
- package/docs/templates/artifact-page/diagram.html +50 -0
- package/hooks/blocking/artifact_look_gate.py +223 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +8 -1
- package/hooks/blocking/pull_request_proof.py +132 -0
- package/hooks/blocking/reply_length_gate.py +26 -0
- package/hooks/blocking/test_artifact_look_gate.py +152 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +15 -0
- package/hooks/blocking/test_pull_request_proof.py +77 -0
- package/hooks/blocking/test_reply_length_gate.py +52 -0
- package/hooks/features/conduct-gates.md +4 -1
- package/hooks/features/session-context.md +1 -1
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/artifact_look_gate_constants.py +33 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +2 -2
- package/hooks/hooks_constants/pull_request_proof_constants.py +25 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +9 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
|
@@ -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
|
|
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
|
|
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
|
|
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  -> 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
|
|
@@ -17,6 +17,13 @@ It denies a colon that joins two clauses on one line, and an em dash::
|
|
|
17
17
|
ok: Two checks failed at 9:47, then the list follows:
|
|
18
18
|
(each item on its own line below the colon)
|
|
19
19
|
|
|
20
|
+
It denies a causal sentence ("so", "because", "caused", "due to",
|
|
21
|
+
"therefore", "means") that carries no inline code, URL, or markdown link
|
|
22
|
+
of its own. A command block in the same reply is no evidence::
|
|
23
|
+
|
|
24
|
+
flag: That window was Windows PowerShell without Administrator, so the write did not land.
|
|
25
|
+
ok: The write did not land, because the next read printed `Right after write: 1`.
|
|
26
|
+
|
|
20
27
|
It denies a banned word or phrase, matched whole and case-insensitive::
|
|
21
28
|
|
|
22
29
|
flag: The cause is likely the cache.
|
|
@@ -58,6 +65,7 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
58
65
|
ALL_DECISION_CARD_PROSE_KEYS,
|
|
59
66
|
ALL_DECISION_OPTION_PROSE_KEYS,
|
|
60
67
|
ALL_DEFAULT_BANNED_WORDS,
|
|
68
|
+
ALL_EVIDENCE_PATTERNS,
|
|
61
69
|
ALLOW_EXIT_CODE,
|
|
62
70
|
BANNED_WORD_MESSAGE,
|
|
63
71
|
BANNED_WORD_PART_SEPARATOR,
|
|
@@ -66,6 +74,7 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
66
74
|
BANNED_WORDS_JSON_KEY,
|
|
67
75
|
BANNED_WORDS_PATH_ENV_VAR,
|
|
68
76
|
BLOCK_EXIT_CODE,
|
|
77
|
+
CAUSAL_CLAIM_PATTERN,
|
|
69
78
|
CARD_TEXT_SEPARATOR,
|
|
70
79
|
CLAUDE_HOME_DIRECTORY_NAME,
|
|
71
80
|
CONFIG_FILE_ENCODING,
|
|
@@ -94,6 +103,7 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
94
103
|
TOOL_NAME_KEY,
|
|
95
104
|
UNLINKED_PULL_REQUEST_MESSAGE,
|
|
96
105
|
UNLINKED_PULL_REQUEST_PATTERN,
|
|
106
|
+
UNSOURCED_CAUSE_MESSAGE,
|
|
97
107
|
URL_PATTERN,
|
|
98
108
|
WORD_PATTERN,
|
|
99
109
|
WORD_SEPARATOR,
|
|
@@ -151,6 +161,21 @@ def em_dash_violation(reply_text: str) -> str | None:
|
|
|
151
161
|
return EM_DASH_MESSAGE
|
|
152
162
|
|
|
153
163
|
|
|
164
|
+
def unsourced_cause_violation(reply_text: str) -> str | None:
|
|
165
|
+
"""Return the deny reason for the first causal sentence that cites no evidence itself, or None."""
|
|
166
|
+
all_sentences = [
|
|
167
|
+
each_sentence
|
|
168
|
+
for each_line in LINE_BREAK_PATTERN.split(FENCED_BLOCK_PATTERN.sub(" ", reply_text))
|
|
169
|
+
for each_sentence in SENTENCE_END_PATTERN.split(each_line)
|
|
170
|
+
]
|
|
171
|
+
for each_sentence in all_sentences:
|
|
172
|
+
is_causal = CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
|
|
173
|
+
has_evidence = any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
|
|
174
|
+
if is_causal and not has_evidence:
|
|
175
|
+
return UNSOURCED_CAUSE_MESSAGE.format(sentence=each_sentence.strip())
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
154
179
|
def unlinked_pull_request_violation(reply_text: str) -> str | None:
|
|
155
180
|
"""Return the deny reason for a pull request number outside a link, or None."""
|
|
156
181
|
without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
|
|
@@ -231,6 +256,7 @@ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tupl
|
|
|
231
256
|
or mid_sentence_colon_violation(reply_text)
|
|
232
257
|
or em_dash_violation(reply_text)
|
|
233
258
|
or unlinked_pull_request_violation(reply_text)
|
|
259
|
+
or unsourced_cause_violation(reply_text)
|
|
234
260
|
or banned_word_violation(reply_text, configured_banned_words())
|
|
235
261
|
)
|
|
236
262
|
return None if violation is None else (violation, reply_text)
|
|
@@ -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 + "\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
|
|
@@ -487,3 +487,55 @@ def test_should_deny_an_em_dash_and_allow_one_in_code(
|
|
|
487
487
|
)
|
|
488
488
|
assert (denied_code, allowed_code) == (2, 0)
|
|
489
489
|
assert "em dash" in denied_text
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def test_should_deny_the_quoted_cause_with_no_source(
|
|
493
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
494
|
+
) -> None:
|
|
495
|
+
exit_code, stderr_text = run_gate(
|
|
496
|
+
monkeypatch,
|
|
497
|
+
capsys,
|
|
498
|
+
POST_TOOL_NAME,
|
|
499
|
+
{"text": "That window was Windows PowerShell without Administrator, so the write did not land."},
|
|
500
|
+
)
|
|
501
|
+
assert exit_code == 2
|
|
502
|
+
assert "Cause with no source" in stderr_text
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@pytest.mark.parametrize(
|
|
506
|
+
"reply_text",
|
|
507
|
+
[
|
|
508
|
+
"The write did not land, because the next read printed `Right after write: 1`.",
|
|
509
|
+
"The job failed because of [run 12](https://github.com/owner/repo/actions/runs/12).",
|
|
510
|
+
],
|
|
511
|
+
)
|
|
512
|
+
def test_should_allow_a_cause_that_cites_evidence(
|
|
513
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
514
|
+
) -> None:
|
|
515
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
|
|
516
|
+
assert (exit_code, stderr_text) == (0, "")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@pytest.mark.parametrize(
|
|
520
|
+
"reply_text",
|
|
521
|
+
[
|
|
522
|
+
"That window lacked Administrator, so the write did not land.\n\n```powershell\nRestart-Service x\n```",
|
|
523
|
+
"The error shows the hook is the only line. So the filter wrote nothing.",
|
|
524
|
+
"One line survived because its start did not match my filter. Run this.",
|
|
525
|
+
],
|
|
526
|
+
)
|
|
527
|
+
def test_should_deny_a_cause_whose_own_sentence_cites_nothing(
|
|
528
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
529
|
+
) -> None:
|
|
530
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
|
|
531
|
+
assert exit_code == 2
|
|
532
|
+
assert "Cause with no source" in stderr_text
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_should_allow_a_reply_with_no_causal_claim(
|
|
536
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
537
|
+
) -> None:
|
|
538
|
+
exit_code, _ = run_gate(
|
|
539
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The runner restarted. Also, so far so good."}
|
|
540
|
+
)
|
|
541
|
+
assert exit_code == 0
|
|
@@ -4,7 +4,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
|
-
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number.
|
|
7
|
+
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link.
|
|
8
8
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
9
9
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
10
10
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
@@ -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.
|
|
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" / "
|
|
25
|
+
Path(__file__).resolve().parents[2] / "docs" / "templates" / "artifact-page" / "README.md"
|
|
26
26
|
)
|
|
27
|
-
POINTER_TEXT_PREFIX = "
|
|
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
|
+
)
|
|
@@ -40,6 +40,15 @@ MID_SENTENCE_COLON_MESSAGE = (
|
|
|
40
40
|
" and put the list on the lines below."
|
|
41
41
|
)
|
|
42
42
|
EM_DASH_MESSAGE = "An em dash is in the text. Use a period or a comma."
|
|
43
|
+
CAUSAL_CLAIM_PATTERN = re.compile(
|
|
44
|
+
r"(?:^|,)\s*so\b(?!\s+far\b)|\b(?:because|caused|causes|due to|therefore|which means|that means)\b",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
ALL_EVIDENCE_PATTERNS = (INLINE_CODE_PATTERN, URL_PATTERN, MARKDOWN_LINK_PATTERN)
|
|
48
|
+
UNSOURCED_CAUSE_MESSAGE = (
|
|
49
|
+
'Cause with no source: "{sentence}". Quote the log line or output in backticks,'
|
|
50
|
+
" or link the run or file and line that shows it."
|
|
51
|
+
)
|
|
43
52
|
ALL_DEFAULT_BANNED_WORDS = (
|
|
44
53
|
"real",
|
|
45
54
|
"really",
|
package/package.json
CHANGED
|
@@ -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_",)
|