claude-dev-env 8.73.1 → 8.73.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/test_agent_frontmatter.py +23 -23
- package/.agents/agents-archived/clean-coder.md +5 -5
- package/.agents/skills/correction/playbooks/fix.md +1 -1
- package/.agents/skills/eli5/SKILL.md +3 -3
- package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.policy-lint.test.mjs +1 -0
- package/bin/install.profile-root.test.mjs +1 -1
- package/bin/install.test.mjs +1 -0
- package/commands/fix-followups.md +1 -1
- package/commands/sr-loop.md +1 -1
- package/docs/references/prose-style-enforcement.md +1 -1
- package/docs/references/working-style.md +1 -1
- package/docs/rule-guides/asd-ste100-language.md +9 -1
- package/docs/rule-guides/cleanup-temp-files.md +8 -2
- package/docs/rule-guides/destructive-commands.md +13 -3
- package/docs/rule-guides/explore-thoroughly.md +9 -1
- package/docs/rule-guides/features-start-with-an-eval.md +8 -2
- package/docs/rule-guides/filesystem-search.md +7 -1
- package/docs/rule-guides/long-horizon-autonomy.md +1 -1
- package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
- package/docs/rule-guides/no-contrast-framing.md +10 -4
- package/docs/rule-guides/prefer-existing-tools.md +2 -2
- package/docs/rule-guides/proof-before-pull-request.md +5 -1
- package/docs/rule-guides/request-scope.md +5 -1
- package/docs/rule-guides/research-mode.md +9 -1
- package/docs/rule-guides/session-title.md +7 -1
- package/docs/rule-guides/shell-invocation.md +9 -1
- package/docs/rule-guides/verify-before-asking.md +7 -1
- package/docs/rule-guides/verify-runtime-state.md +7 -1
- package/hooks/blocking/code_rules_enforcer.py +16 -6
- package/hooks/blocking/code_rules_shared.py +23 -4
- package/hooks/blocking/reply_length_gate.py +74 -14
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
- package/hooks/blocking/test_code_rules_shared.py +33 -0
- package/hooks/blocking/test_reply_length_gate.py +184 -5
- package/hooks/blocking/test_state_description_blocker.py +1 -1
- package/hooks/features/conduct-gates.md +3 -6
- package/hooks/hooks.json +1 -6
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
- package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
- package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
- package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
- package/hooks/hooks_constants/windows_filesystem.py +51 -0
- package/hooks/session/session_env_cleanup.py +4 -30
- package/hooks/test_visual_reply_rules.py +164 -0
- package/hooks/visual_reply_rules.py +268 -0
- package/hooks/workflow/test_auto_formatter.py +4 -25
- package/package.json +1 -1
- package/rules/index.md +30 -0
- package/rules/visual-reply-rules.json +41 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/conftest.py +13 -5
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
- package/scripts/policy_lint/adapter_configuration.py +0 -5
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/test_active_capability_references.py +0 -5
- package/scripts/test_broker_state_isolation.py +3 -3
- package/scripts/test_digest_check.py +0 -5
- package/scripts/test_durable_post_lint.py +1 -5
- package/scripts/test_followup_cli.py +0 -6
- package/scripts/test_resolve_worker_spawn.py +5 -10
- package/scripts/test_setup_project_paths.py +0 -12
- package/scripts/test_shared_tree_paths.py +2 -6
- package/scripts/test_validate_instruction_pairs.py +0 -7
- package/scripts/test_verify_installable_package.py +0 -5
- package/scripts/tests/test_contrast_framing.py +3 -3
- package/scripts/tests/test_rule_index_shape.py +59 -5
- package/scripts/tests/test_rule_load_scopes.py +1 -16
- package/system-prompts/software-engineer.xml +4 -4
- package/hooks/blocking/artifact_look_gate.py +0 -223
- package/hooks/blocking/test_artifact_look_gate.py +0 -153
- package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
- package/rules/asd-ste100-language.md +0 -9
- package/rules/cleanup-temp-files.md +0 -9
- package/rules/destructive-commands.md +0 -11
- package/rules/explore-thoroughly.md +0 -9
- package/rules/features-start-with-an-eval.md +0 -9
- package/rules/filesystem-search.md +0 -9
- package/rules/memory-stores-durable-facts.md +0 -9
- package/rules/no-contrast-framing.md +0 -9
- package/rules/proof-before-pull-request.md +0 -7
- package/rules/request-scope.md +0 -7
- package/rules/research-mode.md +0 -9
- package/rules/session-title.md +0 -9
- package/rules/shell-invocation.md +0 -9
- package/rules/skill-pointers.md +0 -3
- package/rules/verify-before-asking.md +0 -9
- package/rules/verify-runtime-state.md +0 -9
|
@@ -3,23 +3,11 @@
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
import subprocess
|
|
6
|
-
import sys
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from unittest.mock import patch
|
|
9
8
|
|
|
10
9
|
import pytest
|
|
11
10
|
|
|
12
|
-
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
13
|
-
_HOOKS_DIR = _SCRIPTS_DIR.parent / "hooks"
|
|
14
|
-
_SESSION_HOOKS_PACKAGE_DIR = _SCRIPTS_DIR.parent / "hooks" / "session"
|
|
15
|
-
for each_sys_path_entry in (
|
|
16
|
-
str(_SCRIPTS_DIR),
|
|
17
|
-
str(_HOOKS_DIR),
|
|
18
|
-
str(_SESSION_HOOKS_PACKAGE_DIR),
|
|
19
|
-
):
|
|
20
|
-
if each_sys_path_entry not in sys.path:
|
|
21
|
-
sys.path.insert(0, each_sys_path_entry)
|
|
22
|
-
|
|
23
11
|
import setup_project_paths as setup
|
|
24
12
|
import untracked_repo_detector as detector_module
|
|
25
13
|
from hooks_constants.project_paths_reader import registry_file_path
|
|
@@ -9,12 +9,8 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
15
|
-
|
|
16
|
-
import shared_tree_paths # noqa: E402
|
|
17
|
-
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
12
|
+
import shared_tree_paths
|
|
13
|
+
from dev_env_scripts_constants.grok_worker_constants import (
|
|
18
14
|
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
19
15
|
PROCESS_TREE_DIRECTORY_NAME,
|
|
20
16
|
PROCESS_TREE_KILL_MODULE_FILENAME,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
4
|
from validate_instruction_pairs import (
|
|
7
5
|
GATE_FAILED_EXIT_CODE,
|
|
8
6
|
GATE_PASSED_EXIT_CODE,
|
|
@@ -10,11 +8,6 @@ from validate_instruction_pairs import (
|
|
|
10
8
|
run_gate,
|
|
11
9
|
validate_repository,
|
|
12
10
|
)
|
|
13
|
-
|
|
14
|
-
_hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
|
|
15
|
-
if _hooks_directory not in sys.path:
|
|
16
|
-
sys.path.insert(0, _hooks_directory)
|
|
17
|
-
|
|
18
11
|
from followup_ledger import all_recorded_findings, head_commit
|
|
19
12
|
from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
|
|
20
13
|
|
|
@@ -6,14 +6,9 @@ command walking without requiring a full npm pack on every case.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import sys
|
|
10
9
|
import tarfile
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
|
|
13
|
-
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
14
|
-
if str(_SCRIPTS_DIR) not in sys.path:
|
|
15
|
-
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
16
|
-
|
|
17
12
|
import verify_installable_package as mod
|
|
18
13
|
from dev_env_scripts_constants.verify_installable_package_constants import (
|
|
19
14
|
CLASS_PACKAGED,
|
|
@@ -89,12 +89,12 @@ def test_form_named_rejects_an_unknown_name() -> None:
|
|
|
89
89
|
form_named("no-such-form")
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def
|
|
93
|
-
|
|
92
|
+
def test_every_form_is_named_in_the_rule_guide() -> None:
|
|
93
|
+
guide_text = (PACKAGE_ROOT / CONTRAST_FRAMING_RULE_DOCUMENT).read_text(
|
|
94
94
|
encoding="utf-8"
|
|
95
95
|
)
|
|
96
96
|
for each_form in ALL_CONTRAST_FRAMING_FORMS:
|
|
97
|
-
assert f"`{each_form.name}`" in
|
|
97
|
+
assert f"`{each_form.name}`" in guide_text
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
def test_a_list_of_not_items_stays_quiet() -> None:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The rules index and each rule entry keep their shape, and the always-loaded rules stay inside a byte budget."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import sys
|
|
@@ -28,11 +28,16 @@ DOUBLED_WHEN_LEAD_IN_PATTERN = re.compile(
|
|
|
28
28
|
r"^\*\*When(?: this applies)?:\*\* When\b", re.MULTILINE
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
+
INDEX_LINE_PATTERN = re.compile(r"^- \*\*[^*]+\*\*(?: \(\[guide\]\((\.\./docs/rule-guides/[^)#\s]+\.md)\)\))?\. \S")
|
|
32
|
+
INDEX_SKILL_POINTER_PATTERN = re.compile(r"the `[a-z-]+` skill")
|
|
33
|
+
|
|
34
|
+
INDEX_FILE_NAME = "index.md"
|
|
35
|
+
INDEX_PATH = RULES_DIRECTORY / INDEX_FILE_NAME
|
|
31
36
|
MAXIMUM_ENTRY_BYTES = 1_500
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
MAXIMUM_INDEX_BYTES = 4_000
|
|
38
|
+
MAXIMUM_ALWAYS_ON_BYTES = 6_000
|
|
34
39
|
CODEX_VERBATIM_ENTRY_NAMES = frozenset({"question-presentation.md"})
|
|
35
|
-
INDEX_SHAPE_EXEMPT_ENTRY_NAMES =
|
|
40
|
+
INDEX_SHAPE_EXEMPT_ENTRY_NAMES = CODEX_VERBATIM_ENTRY_NAMES
|
|
36
41
|
CODEX_MATERIALIZED_GUIDE_NAMES = frozenset(
|
|
37
42
|
Path(each_path).name for each_path in codex_instruction_rule_relative_paths
|
|
38
43
|
)
|
|
@@ -43,6 +48,7 @@ def _entry_paths() -> list[Path]:
|
|
|
43
48
|
each_path
|
|
44
49
|
for each_path in RULES_DIRECTORY.glob("*.md")
|
|
45
50
|
if each_path.name not in instruction_alias_filenames
|
|
51
|
+
and each_path.name != INDEX_FILE_NAME
|
|
46
52
|
)
|
|
47
53
|
|
|
48
54
|
|
|
@@ -183,11 +189,59 @@ def test_each_entry_links_a_full_text_guide_that_exists() -> None:
|
|
|
183
189
|
assert all_problems == []
|
|
184
190
|
|
|
185
191
|
|
|
192
|
+
def _index_lines() -> list[str]:
|
|
193
|
+
return [
|
|
194
|
+
each_line
|
|
195
|
+
for each_line in INDEX_PATH.read_text(encoding="utf-8").splitlines()
|
|
196
|
+
if each_line.startswith("- ")
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _index_guide_names() -> list[str]:
|
|
201
|
+
return [
|
|
202
|
+
Path(each_match.group(1)).name
|
|
203
|
+
for each_line in _index_lines()
|
|
204
|
+
if (each_match := INDEX_LINE_PATTERN.match(each_line)) and each_match.group(1)
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_each_index_line_names_a_rule_and_a_trigger() -> None:
|
|
209
|
+
malformed = [
|
|
210
|
+
each_line for each_line in _index_lines() if not INDEX_LINE_PATTERN.match(each_line)
|
|
211
|
+
]
|
|
212
|
+
assert malformed == []
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_each_index_line_links_a_guide_or_names_a_skill() -> None:
|
|
216
|
+
unlinked = [
|
|
217
|
+
each_line
|
|
218
|
+
for each_line in _index_lines()
|
|
219
|
+
if not INDEX_LINE_PATTERN.match(each_line).group(1)
|
|
220
|
+
and not INDEX_SKILL_POINTER_PATTERN.search(each_line)
|
|
221
|
+
]
|
|
222
|
+
assert unlinked == []
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_each_index_guide_exists() -> None:
|
|
226
|
+
missing = [
|
|
227
|
+
each_guide_name
|
|
228
|
+
for each_guide_name in _index_guide_names()
|
|
229
|
+
if not (RULE_GUIDES_DIRECTORY / each_guide_name).is_file()
|
|
230
|
+
]
|
|
231
|
+
assert missing == []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_index_stays_inside_its_byte_budget() -> None:
|
|
235
|
+
assert len(INDEX_PATH.read_bytes()) <= MAXIMUM_INDEX_BYTES
|
|
236
|
+
|
|
237
|
+
|
|
186
238
|
def test_each_guide_has_exactly_one_owner() -> None:
|
|
187
239
|
owners_by_guide: dict[str, list[str]] = {}
|
|
188
240
|
for each_path in _entry_paths():
|
|
189
241
|
for each_guide_name in _linked_guide_names(_entry_text(each_path)):
|
|
190
242
|
owners_by_guide.setdefault(each_guide_name, []).append(each_path.name)
|
|
243
|
+
for each_guide_name in _index_guide_names():
|
|
244
|
+
owners_by_guide.setdefault(each_guide_name, []).append(INDEX_FILE_NAME)
|
|
191
245
|
unowned = sorted(
|
|
192
246
|
each_guide.name
|
|
193
247
|
for each_guide in RULE_GUIDES_DIRECTORY.glob("*.md")
|
|
@@ -204,7 +258,7 @@ def test_each_guide_has_exactly_one_owner() -> None:
|
|
|
204
258
|
|
|
205
259
|
|
|
206
260
|
def test_always_loaded_entries_stay_inside_the_total_byte_budget() -> None:
|
|
207
|
-
always_on_bytes = sum(
|
|
261
|
+
always_on_bytes = len(INDEX_PATH.read_bytes()) + sum(
|
|
208
262
|
len(each_path.read_bytes())
|
|
209
263
|
for each_path in _entry_paths()
|
|
210
264
|
if _loads_in_every_session(_entry_text(each_path))
|
|
@@ -8,24 +8,9 @@ SKILLS_DIRECTORY = PACKAGE_ROOT / ".agents" / "skills"
|
|
|
8
8
|
|
|
9
9
|
ALWAYS_ON_RULE_NAMES = frozenset(
|
|
10
10
|
{
|
|
11
|
-
"asd-ste100-language.md",
|
|
12
|
-
"cleanup-temp-files.md",
|
|
13
11
|
"correction-lens.md",
|
|
14
|
-
"
|
|
15
|
-
"explore-thoroughly.md",
|
|
16
|
-
"features-start-with-an-eval.md",
|
|
17
|
-
"filesystem-search.md",
|
|
18
|
-
"memory-stores-durable-facts.md",
|
|
19
|
-
"no-contrast-framing.md",
|
|
20
|
-
"proof-before-pull-request.md",
|
|
12
|
+
"index.md",
|
|
21
13
|
"question-presentation.md",
|
|
22
|
-
"research-mode.md",
|
|
23
|
-
"request-scope.md",
|
|
24
|
-
"session-title.md",
|
|
25
|
-
"skill-pointers.md",
|
|
26
|
-
"shell-invocation.md",
|
|
27
|
-
"verify-before-asking.md",
|
|
28
|
-
"verify-runtime-state.md",
|
|
29
14
|
}
|
|
30
15
|
)
|
|
31
16
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
</role>
|
|
8
8
|
|
|
9
9
|
<task_scope>
|
|
10
|
-
Apply
|
|
10
|
+
Apply docs/rule-guides/asd-ste100-language.md for user-facing word choice, sentence style, tone, punctuation,
|
|
11
11
|
and prose form. Keep the behavior contracts below within their named scope.
|
|
12
12
|
Defer to the user's judgment when they indicate a task is too ambitious or complex to pursue.
|
|
13
13
|
Match the scope of every action to what the user explicitly requested: a bug fix delivers the
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
<output_style>
|
|
29
29
|
When a user-facing response needs beginner presentation, use ELI5: beginner framing, large
|
|
30
30
|
visuals, minimal text, one stable self-contained HTML artifact, update-in-place continuity,
|
|
31
|
-
and sharing. Apply
|
|
31
|
+
and sharing. Apply docs/rule-guides/asd-ste100-language.md to sentence-level prose inside that artifact.
|
|
32
32
|
Lead every response with the direct answer or result. Open with the substance — the
|
|
33
33
|
conclusion, the fix, the decision — before any supporting context or explanation. Expand
|
|
34
34
|
depth in proportion to the complexity of the ask: a direct question earns a sentence; a
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
Reserve user questions for preferences, missing context the user holds, judgment calls, and
|
|
68
68
|
scope decisions.
|
|
69
69
|
|
|
70
|
-
Source every claim as
|
|
70
|
+
Source every claim as docs/rule-guides/research-mode.md describes.
|
|
71
71
|
</investigation>
|
|
72
72
|
|
|
73
73
|
<problem_solving>
|
|
@@ -363,7 +363,7 @@
|
|
|
363
363
|
Visible output is concise by default. The first progress update is one sentence.
|
|
364
364
|
Later updates mark only important discoveries or direction changes. The final
|
|
365
365
|
response begins with the outcome.
|
|
366
|
-
Language policy:
|
|
366
|
+
Language policy: docs/rule-guides/asd-ste100-language.md. This section owns visible progress,
|
|
367
367
|
tool narration, and outcome-first final structure.
|
|
368
368
|
</communication_contract>
|
|
369
369
|
|
|
@@ -1,223 +0,0 @@
|
|
|
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())
|
|
@@ -1,153 +0,0 @@
|
|
|
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
|
-
assert "Attach" not in specific_output["permissionDecisionReason"]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def test_should_allow_a_page_whose_newer_screenshot_was_read(tmp_path: Path) -> None:
|
|
108
|
-
page_path = _file_at(tmp_path / "page.html", 1000)
|
|
109
|
-
image_path = _file_at(tmp_path / "page.png", 1001)
|
|
110
|
-
assert _run_hook(tmp_path, _read_records(image_path), page_path) == ""
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_should_refuse_when_the_page_changed_after_the_screenshot(tmp_path: Path) -> None:
|
|
114
|
-
image_path = _file_at(tmp_path / "page.png", 1000)
|
|
115
|
-
page_path = _file_at(tmp_path / "page.html", 1001)
|
|
116
|
-
assert "deny" in _run_hook(tmp_path, _read_records(image_path), page_path)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def test_should_refuse_when_the_screenshot_read_failed(tmp_path: Path) -> None:
|
|
120
|
-
page_path = _file_at(tmp_path / "page.html", 1000)
|
|
121
|
-
image_path = _file_at(tmp_path / "page.png", 1001)
|
|
122
|
-
assert "deny" in _run_hook(tmp_path, _read_records(image_path, is_error=True), page_path)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def test_should_refuse_a_screenshot_overwritten_after_its_read(tmp_path: Path) -> None:
|
|
126
|
-
page_path = _file_at(tmp_path / "page.html", 1000)
|
|
127
|
-
image_path = _file_at(tmp_path / "page.png", 1001)
|
|
128
|
-
read_before_the_page_changed = "1970-01-01T00:16:39Z"
|
|
129
|
-
assert "deny" in _run_hook(
|
|
130
|
-
tmp_path, _read_records(image_path, timestamp=read_before_the_page_changed), page_path
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def test_page_was_looked_at_ignores_reads_of_other_files(tmp_path: Path) -> None:
|
|
135
|
-
page_path = _file_at(tmp_path / "page.html", 1000)
|
|
136
|
-
notes_path = _file_at(tmp_path / "notes.md", 1001)
|
|
137
|
-
all_lines = [json.dumps(each_record) for each_record in _read_records(notes_path)]
|
|
138
|
-
assert not artifact_look_gate.page_was_looked_at(all_lines, str(page_path))
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def test_unseen_page_reason_passes_non_publish_calls() -> None:
|
|
142
|
-
assert (
|
|
143
|
-
artifact_look_gate.unseen_page_reason(
|
|
144
|
-
{"tool_name": "Artifact", "tool_input": {"action": "read", "url": "x"}}
|
|
145
|
-
)
|
|
146
|
-
is None
|
|
147
|
-
)
|
|
148
|
-
assert (
|
|
149
|
-
artifact_look_gate.unseen_page_reason(
|
|
150
|
-
{"tool_name": "Artifact", "tool_input": {"file_path": "image.png", "asset": True}}
|
|
151
|
-
)
|
|
152
|
-
is None
|
|
153
|
-
)
|
|
@@ -1,32 +0,0 @@
|
|
|
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."
|
|
32
|
-
)
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# ASD-STE100 language policy
|
|
2
|
-
|
|
3
|
-
**When:** Writing chat, tool narration, or repository prose.
|
|
4
|
-
|
|
5
|
-
Use the sole general language rule. Write short, complete sentences on one topic. Use active voice; lead with conditions; one action per step. Use plain, precise words, stable terms. Expand abbreviations and contractions; name unclear pronouns. Be inclusive; punctuate clearly. Preserve exact labels, identifiers, formulas, titles, interface text. Say each fact once. Send a result, blocker, or question. Mark injury or death `WARNING`, equipment damage `CAUTION`; state condition then result. Aim for 20 words per step, 25 per description. Treat checks as aids; have a human verify accuracy, terms, safety, confidentiality, meaning.
|
|
6
|
-
|
|
7
|
-
**Enforcement:** none.
|
|
8
|
-
|
|
9
|
-
**Full text:** [guide](../docs/rule-guides/asd-ste100-language.md). Read it for sources.
|