claude-dev-env 8.73.0 → 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__code_review_test_support.py +40 -0
- package/scripts/test_active_capability_references.py +0 -5
- package/scripts/test_broker_state_isolation.py +3 -3
- package/scripts/test_codec_forwarding_test_support.py +28 -0
- 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
|
@@ -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,
|
|
7
|
+
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, 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. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
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,12 +12,11 @@ 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.
|
|
16
15
|
- `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.
|
|
17
16
|
|
|
18
17
|
## When it fires
|
|
19
18
|
|
|
20
|
-
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message`, timeout `10` seconds in `hooks.json`.
|
|
19
|
+
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
|
|
21
20
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
22
21
|
- `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
|
|
23
22
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
@@ -25,7 +24,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
25
24
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
26
25
|
- `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`.
|
|
27
26
|
- `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|actions_run_trigger)`, 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.
|
|
29
27
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
30
28
|
|
|
31
29
|
## Proving it
|
|
@@ -34,7 +32,7 @@ Preconditions:
|
|
|
34
32
|
|
|
35
33
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
36
34
|
|
|
37
|
-
- **Reply length.** Input is a reply with four sentences
|
|
35
|
+
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason.
|
|
38
36
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
39
37
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
40
38
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
@@ -52,5 +50,4 @@ Preconditions:
|
|
|
52
50
|
- The edit marker gate scans replacement cards as well as the message text.
|
|
53
51
|
- 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.
|
|
54
52
|
- 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.
|
|
56
53
|
- 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.
|
package/hooks/hooks.json
CHANGED
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
]
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
|
-
"matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision",
|
|
121
|
+
"matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget",
|
|
122
122
|
"hooks": [
|
|
123
123
|
{
|
|
124
124
|
"type": "command",
|
|
@@ -174,11 +174,6 @@
|
|
|
174
174
|
"type": "command",
|
|
175
175
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/artifact_template_pointer.py",
|
|
176
176
|
"timeout": 10
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
"type": "command",
|
|
180
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_look_gate.py",
|
|
181
|
-
"timeout": 10
|
|
182
177
|
}
|
|
183
178
|
]
|
|
184
179
|
},
|
|
@@ -42,7 +42,7 @@ ALL_AGENT_HOME_TOOLING_PATTERNS = {"/.grok/"}
|
|
|
42
42
|
ALL_HOOK_INFRASTRUCTURE_PATTERNS = {
|
|
43
43
|
"/.claude/hooks/",
|
|
44
44
|
"/packages/claude-dev-env/hooks/",
|
|
45
|
-
}
|
|
45
|
+
}
|
|
46
46
|
ALL_WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "_tab.py", "/states.py", "/modules.py"}
|
|
47
47
|
ALL_MIGRATION_PATH_PATTERNS = {"/migrations/"}
|
|
48
48
|
|
|
@@ -13,6 +13,7 @@ ALL_DECISION_OPTION_PROSE_KEYS = ("label", "consequence")
|
|
|
13
13
|
TOOL_NAME_KEY = "tool_name"
|
|
14
14
|
TOOL_INPUT_KEY = "tool_input"
|
|
15
15
|
TEXT_KEY = "text"
|
|
16
|
+
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
16
17
|
HOOK_EVENT_NAME = "PreToolUse"
|
|
17
18
|
ALLOW_EXIT_CODE = 0
|
|
18
19
|
BLOCK_EXIT_CODE = 2
|
|
@@ -11,8 +11,6 @@ SECONDS_PER_DAY = 24 * 60 * 60
|
|
|
11
11
|
STALE_AGE_DAYS = 7
|
|
12
12
|
STALE_AGE_SECONDS = STALE_AGE_DAYS * SECONDS_PER_DAY
|
|
13
13
|
|
|
14
|
-
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS: tuple[int, int] = (3, 12)
|
|
15
|
-
|
|
16
14
|
SESSION_ID_PAYLOAD_KEY: str = "session_id"
|
|
17
15
|
|
|
18
16
|
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from hooks_constants import visual_reply_rules_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_rules_file_path_points_at_the_shipped_rule_list() -> None:
|
|
5
|
+
assert constants.RULES_FILE_PATH.is_file()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_capitalized_abbreviation_pattern_needs_two_capitals() -> None:
|
|
9
|
+
assert constants.CAPITALIZED_ABBREVIATION_PATTERN.findall("CI and PRs, not Item or I") == ["CI", "PRs"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_tracker_number_pattern_reads_items_issues_and_cards() -> None:
|
|
13
|
+
assert [
|
|
14
|
+
each.group(0) for each in constants.TRACKER_NUMBER_PATTERN.finditer("issue 5735, item #6, card 2")
|
|
15
|
+
] == ["issue 5735", "item #6", "card 2"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Tests for the shared Windows-safe force_rmtree helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
13
|
+
|
|
14
|
+
from hooks_constants import windows_filesystem
|
|
15
|
+
from hooks_constants.windows_filesystem import force_rmtree
|
|
16
|
+
|
|
17
|
+
_original_unlink = os.unlink
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _unlink_refusing_read_only_files(path, *, dir_fd=None):
|
|
21
|
+
file_mode = os.stat(path, dir_fd=dir_fd, follow_symlinks=False).st_mode
|
|
22
|
+
if not file_mode & stat.S_IWRITE:
|
|
23
|
+
raise PermissionError(f"read-only file refused: {path}")
|
|
24
|
+
_original_unlink(path, dir_fd=dir_fd)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def tree_with_read_only_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
29
|
+
tree_root = tmp_path / "tree"
|
|
30
|
+
nested_directory = tree_root / "nested"
|
|
31
|
+
nested_directory.mkdir(parents=True)
|
|
32
|
+
read_only_file = nested_directory / "pack.idx"
|
|
33
|
+
read_only_file.write_text("packed", encoding="utf-8")
|
|
34
|
+
os.chmod(read_only_file, stat.S_IREAD)
|
|
35
|
+
monkeypatch.setattr(os, "unlink", _unlink_refusing_read_only_files)
|
|
36
|
+
with pytest.raises(PermissionError):
|
|
37
|
+
os.unlink(read_only_file)
|
|
38
|
+
return tree_root
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_removes_tree_holding_read_only_file(tree_with_read_only_file: Path) -> None:
|
|
42
|
+
force_rmtree(str(tree_with_read_only_file))
|
|
43
|
+
assert not tree_with_read_only_file.exists()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_removes_read_only_file_through_onerror_callback(
|
|
47
|
+
tree_with_read_only_file: Path, monkeypatch: pytest.MonkeyPatch
|
|
48
|
+
) -> None:
|
|
49
|
+
monkeypatch.setattr(windows_filesystem, "_rmtree_supports_onexc", False)
|
|
50
|
+
force_rmtree(str(tree_with_read_only_file))
|
|
51
|
+
assert not tree_with_read_only_file.exists()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_missing_tree_does_not_raise(tmp_path: Path) -> None:
|
|
55
|
+
missing_tree = tmp_path / "absent"
|
|
56
|
+
force_rmtree(str(missing_tree))
|
|
57
|
+
assert not missing_tree.exists()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Constants for the visual reply mode checks in the reply length gate."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
RULES_FILE_PATH = (
|
|
7
|
+
Path(__file__).resolve().parent.parent.parent / "rules" / "visual-reply-rules.json"
|
|
8
|
+
)
|
|
9
|
+
RULES_FILE_ENCODING = "utf-8"
|
|
10
|
+
RULES_LIST_KEY = "rules"
|
|
11
|
+
RULE_KEY_FIELD = "key"
|
|
12
|
+
RULE_LABEL_FIELD = "label"
|
|
13
|
+
RULE_REMINDER_FIELD = "reminder"
|
|
14
|
+
ALLOWED_CAPITALIZED_WORDS_KEY = "allowed_capitalized_words"
|
|
15
|
+
LOWERCASE_ABBREVIATIONS_KEY = "lowercase_abbreviations"
|
|
16
|
+
|
|
17
|
+
MODE_SWITCH_FILE_NAME = "visual-reply-mode.json"
|
|
18
|
+
MODE_SWITCH_ENABLED_KEY = "enabled"
|
|
19
|
+
|
|
20
|
+
NO_PICTURE_RULE_KEY = "no_picture"
|
|
21
|
+
ABBREVIATIONS_RULE_KEY = "abbreviations"
|
|
22
|
+
ONE_ITEM_RULE_KEY = "one_item"
|
|
23
|
+
|
|
24
|
+
WIDGET_TOOL_NAME_SUFFIX = "__post_widget"
|
|
25
|
+
ARTIFACT_TOOL_NAME = "Artifact"
|
|
26
|
+
ALL_VISUAL_TOOL_NAME_SUFFIXES = (WIDGET_TOOL_NAME_SUFFIX, ARTIFACT_TOOL_NAME)
|
|
27
|
+
ALL_REPLY_TOOL_NAME_SUFFIXES = ("__reply", "__post_message")
|
|
28
|
+
MAXIMUM_REPLIES_PER_TURN = 1
|
|
29
|
+
MAXIMUM_SENTENCES_WITHOUT_VISUAL = 1
|
|
30
|
+
|
|
31
|
+
CAPITALIZED_ABBREVIATION_PATTERN = re.compile(
|
|
32
|
+
r"(?<![A-Za-z0-9])(?=[A-Z0-9]*[A-Z][A-Z0-9]*[A-Z])[A-Z0-9]{2,}s?(?![A-Za-z0-9])"
|
|
33
|
+
)
|
|
34
|
+
LOWERCASE_ABBREVIATION_TEMPLATE = r"(?<![A-Za-z0-9]){abbreviation}(?![A-Za-z0-9])"
|
|
35
|
+
TRACKER_NUMBER_PATTERN = re.compile(
|
|
36
|
+
r"\b(?:issues?|items?|cards?|tickets?|tasks?)\s*#?\d+", re.IGNORECASE
|
|
37
|
+
)
|
|
38
|
+
WIDGET_ANCHOR_PATTERN = re.compile(r"<a\b[^>]*\bhref\s*=", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
TRANSCRIPT_ENCODING = "utf-8"
|
|
41
|
+
ENTRY_TYPE_KEY = "type"
|
|
42
|
+
USER_ENTRY_TYPE = "user"
|
|
43
|
+
ASSISTANT_ENTRY_TYPE = "assistant"
|
|
44
|
+
MESSAGE_KEY = "message"
|
|
45
|
+
CONTENT_KEY = "content"
|
|
46
|
+
BLOCK_TYPE_KEY = "type"
|
|
47
|
+
BLOCK_NAME_KEY = "name"
|
|
48
|
+
TEXT_BLOCK_TYPE = "text"
|
|
49
|
+
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
50
|
+
TOOL_RESULT_BLOCK_TYPE = "tool_result"
|
|
51
|
+
BLOCK_ID_KEY = "id"
|
|
52
|
+
TOOL_USE_ID_KEY = "tool_use_id"
|
|
53
|
+
IS_ERROR_KEY = "is_error"
|
|
54
|
+
IS_META_KEY = "isMeta"
|
|
55
|
+
|
|
56
|
+
ABBREVIATION_MESSAGE = 'Abbreviation "{abbreviation}" in the text. {reminder}'
|
|
57
|
+
TRACKER_NUMBER_MESSAGE = 'Tracker number "{reference}" in the text. {reminder}'
|
|
58
|
+
NO_VISUAL_MESSAGE = "This reply has {sentence_count} sentences and no visual this turn. {reminder}"
|
|
59
|
+
SECOND_REPLY_MESSAGE = "A reply already went out this turn. {reminder}"
|
|
60
|
+
WIDGET_ANCHOR_MESSAGE = (
|
|
61
|
+
"A link sits inside the widget code, and a widget link does not open in the Claude app."
|
|
62
|
+
" Remove the anchor and put the page link in the reply text."
|
|
63
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared Windows-safe directory-tree removal for the hooks package.
|
|
2
|
+
|
|
3
|
+
Windows refuses to delete a file that carries the ReadOnly attribute, which git
|
|
4
|
+
sets on pack files and which a test sandbox can leave behind. ``force_rmtree``
|
|
5
|
+
clears the attribute and retries each refused removal, so every hook and test
|
|
6
|
+
that removes a tree imports this one helper.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
__all__ = ["force_rmtree"]
|
|
18
|
+
|
|
19
|
+
_rmtree_supports_onexc = "onexc" in inspect.signature(shutil.rmtree).parameters
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _strip_read_only_and_retry(
|
|
23
|
+
removal_function: Callable[[str], None],
|
|
24
|
+
target_path: str,
|
|
25
|
+
*_unused_exception_info: object,
|
|
26
|
+
) -> None:
|
|
27
|
+
try:
|
|
28
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
29
|
+
removal_function(target_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def force_rmtree(target_path: str) -> None:
|
|
35
|
+
"""Remove a directory tree, clearing the ReadOnly attribute on refused entries.
|
|
36
|
+
|
|
37
|
+
Python 3.12 renamed the rmtree error callback to ``onexc``; earlier versions
|
|
38
|
+
accept only ``onerror``. The helper passes whichever keyword the running
|
|
39
|
+
interpreter's ``shutil.rmtree`` accepts.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
target_path: The directory tree to remove. A tree that cannot be
|
|
43
|
+
removed, including a missing one, is left as is without raising.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
if _rmtree_supports_onexc:
|
|
47
|
+
shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
|
|
48
|
+
else:
|
|
49
|
+
shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
@@ -18,48 +18,22 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
|
-
import shutil
|
|
22
|
-
import stat
|
|
23
21
|
import sys
|
|
24
22
|
import time
|
|
25
23
|
from pathlib import Path
|
|
26
|
-
from typing import Callable
|
|
27
24
|
|
|
28
25
|
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
29
26
|
if _hooks_dir not in sys.path:
|
|
30
27
|
sys.path.insert(0, _hooks_dir)
|
|
31
28
|
|
|
32
|
-
from hooks_constants.session_env_cleanup_constants import (
|
|
33
|
-
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS,
|
|
29
|
+
from hooks_constants.session_env_cleanup_constants import (
|
|
34
30
|
SESSION_ENV_DIRECTORY,
|
|
35
31
|
SESSION_ID_PATTERN,
|
|
36
32
|
SESSION_ID_PAYLOAD_KEY,
|
|
37
33
|
STALE_AGE_SECONDS,
|
|
38
34
|
WINDOWS_PLATFORM_TAG,
|
|
39
35
|
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _strip_read_only_and_retry(
|
|
43
|
-
removal_function: Callable[[str], None],
|
|
44
|
-
target_path: str,
|
|
45
|
-
*_unused_exception_info: object,
|
|
46
|
-
) -> None:
|
|
47
|
-
try:
|
|
48
|
-
os.chmod(target_path, stat.S_IWRITE)
|
|
49
|
-
removal_function(target_path)
|
|
50
|
-
except OSError:
|
|
51
|
-
pass
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _force_rmtree(target_path: str) -> None:
|
|
55
|
-
rmtree_onexc_python_version = ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS
|
|
56
|
-
try:
|
|
57
|
-
if sys.version_info >= rmtree_onexc_python_version:
|
|
58
|
-
shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
|
|
59
|
-
else:
|
|
60
|
-
shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)
|
|
61
|
-
except OSError:
|
|
62
|
-
pass
|
|
36
|
+
from hooks_constants.windows_filesystem import force_rmtree
|
|
63
37
|
|
|
64
38
|
|
|
65
39
|
def prune_session_env(
|
|
@@ -71,7 +45,7 @@ def prune_session_env(
|
|
|
71
45
|
if session_id:
|
|
72
46
|
current_session_path = os.path.join(session_env_directory, session_id)
|
|
73
47
|
if os.path.isdir(current_session_path):
|
|
74
|
-
|
|
48
|
+
force_rmtree(current_session_path)
|
|
75
49
|
if not os.path.isdir(session_env_directory):
|
|
76
50
|
return
|
|
77
51
|
stale_cutoff_seconds = time.time() - stale_age_seconds
|
|
@@ -87,7 +61,7 @@ def prune_session_env(
|
|
|
87
61
|
continue
|
|
88
62
|
if entry_mtime_seconds >= stale_cutoff_seconds:
|
|
89
63
|
continue
|
|
90
|
-
|
|
64
|
+
force_rmtree(entry_path)
|
|
91
65
|
|
|
92
66
|
|
|
93
67
|
def _read_session_id_from_stdin() -> str:
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Behavior tests for the visual reply rule list."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_hooks_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
if _hooks_directory not in sys.path:
|
|
9
|
+
sys.path.insert(0, _hooks_directory)
|
|
10
|
+
|
|
11
|
+
from visual_reply_rules import (
|
|
12
|
+
abbreviation_violation,
|
|
13
|
+
load_rules,
|
|
14
|
+
mode_enabled,
|
|
15
|
+
replies_sent_this_turn,
|
|
16
|
+
second_reply_violation,
|
|
17
|
+
visual_shown_this_turn,
|
|
18
|
+
visual_violation,
|
|
19
|
+
widget_anchor_violation,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SHIPPED_RULES = load_rules()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prompt_line(text: str) -> str:
|
|
26
|
+
return json.dumps({"type": "user", "message": {"content": text}})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def tool_line(tool_name: str) -> str:
|
|
30
|
+
return json.dumps(
|
|
31
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": tool_name}]}}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_shipped_rule_file_parses_with_a_label_and_reminder_per_rule() -> None:
|
|
36
|
+
rules = load_rules()
|
|
37
|
+
assert rules is not None
|
|
38
|
+
assert {each.key for each in rules.all_rules} >= {"one_item", "no_picture", "abbreviations"}
|
|
39
|
+
assert all(each.label and each.reminder for each in rules.all_rules)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_load_rules_returns_none_when_a_needed_rule_is_missing(tmp_path: Path) -> None:
|
|
43
|
+
rules_path = tmp_path / "rules.json"
|
|
44
|
+
rules_path.write_text(
|
|
45
|
+
json.dumps({"rules": [{"key": "no_picture", "label": "No picture", "reminder": "Draw it."}]}),
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
assert load_rules(rules_path) is None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_mode_is_off_without_a_switch_file(tmp_path: Path) -> None:
|
|
52
|
+
assert mode_enabled(tmp_path) is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_mode_is_on_when_the_switch_file_says_so(tmp_path: Path) -> None:
|
|
56
|
+
(tmp_path / "visual-reply-mode.json").write_text('{"enabled": true}', encoding="utf-8")
|
|
57
|
+
assert mode_enabled(tmp_path) is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_abbreviation_violation_names_the_abbreviation_and_allows_listed_words() -> None:
|
|
61
|
+
assert SHIPPED_RULES is not None
|
|
62
|
+
assert 'Abbreviation "UI"' in str(abbreviation_violation("The UI is done.", SHIPPED_RULES))
|
|
63
|
+
assert abbreviation_violation("OK, I merged it.", SHIPPED_RULES) is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_abbreviation_violation_flags_a_lowercase_abbreviation_and_a_tracker_number() -> None:
|
|
67
|
+
assert SHIPPED_RULES is not None
|
|
68
|
+
assert 'Abbreviation "e.g."' in str(abbreviation_violation("Logs, e.g. the error log.", SHIPPED_RULES))
|
|
69
|
+
assert 'Tracker number "card 12"' in str(abbreviation_violation("See card 12.", SHIPPED_RULES))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_widget_anchor_violation_reads_only_widget_calls() -> None:
|
|
73
|
+
anchor_input = {"widget_code": '<a href="https://example.com">page</a>'}
|
|
74
|
+
assert widget_anchor_violation("mcp__hearthbot__post_widget", anchor_input) is not None
|
|
75
|
+
assert widget_anchor_violation("mcp__hearthbot__reply", anchor_input) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_visual_shown_this_turn_counts_only_calls_after_the_last_prompt() -> None:
|
|
79
|
+
assert visual_shown_this_turn([prompt_line("status"), tool_line("Artifact")])
|
|
80
|
+
assert not visual_shown_this_turn(
|
|
81
|
+
[prompt_line("status"), tool_line("mcp__hearthbot__post_widget"), prompt_line("next")]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_visual_violation_reads_the_transcript_file(tmp_path: Path) -> None:
|
|
86
|
+
assert SHIPPED_RULES is not None
|
|
87
|
+
transcript_path = tmp_path / "t.jsonl"
|
|
88
|
+
transcript_path.write_text(prompt_line("status"), encoding="utf-8")
|
|
89
|
+
assert visual_violation(2, str(transcript_path), SHIPPED_RULES) is not None
|
|
90
|
+
assert visual_violation(1, str(transcript_path), SHIPPED_RULES) is None
|
|
91
|
+
assert visual_violation(2, str(tmp_path / "missing.jsonl"), SHIPPED_RULES) is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def reply_use_line(tool_use_id: str) -> str:
|
|
95
|
+
return json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"type": "assistant",
|
|
98
|
+
"message": {
|
|
99
|
+
"content": [{"type": "tool_use", "id": tool_use_id, "name": "mcp__thread__reply"}]
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def result_line(tool_use_id: str, is_error: bool) -> str:
|
|
106
|
+
return json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"type": "user",
|
|
109
|
+
"message": {
|
|
110
|
+
"content": [{"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error}]
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_replies_sent_this_turn_counts_delivered_replies_after_the_last_prompt() -> None:
|
|
117
|
+
all_lines = [
|
|
118
|
+
prompt_line("earlier"),
|
|
119
|
+
reply_use_line("a"),
|
|
120
|
+
result_line("a", False),
|
|
121
|
+
prompt_line("status"),
|
|
122
|
+
reply_use_line("b"),
|
|
123
|
+
result_line("b", True),
|
|
124
|
+
reply_use_line("c"),
|
|
125
|
+
result_line("c", False),
|
|
126
|
+
reply_use_line("d"),
|
|
127
|
+
]
|
|
128
|
+
assert replies_sent_this_turn(all_lines) == 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def widget_use_line(tool_use_id: str) -> str:
|
|
132
|
+
return json.dumps(
|
|
133
|
+
{
|
|
134
|
+
"type": "assistant",
|
|
135
|
+
"message": {
|
|
136
|
+
"content": [
|
|
137
|
+
{"type": "tool_use", "id": tool_use_id, "name": "mcp__hearthbot__post_widget"}
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_visual_shown_this_turn_skips_a_widget_that_came_back_with_an_error() -> None:
|
|
145
|
+
assert not visual_shown_this_turn(
|
|
146
|
+
[prompt_line("status"), widget_use_line("w"), result_line("w", True)]
|
|
147
|
+
)
|
|
148
|
+
assert visual_shown_this_turn([prompt_line("status"), widget_use_line("w"), result_line("w", False)])
|
|
149
|
+
assert visual_shown_this_turn([prompt_line("status"), widget_use_line("w")])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_second_reply_violation_denies_only_after_a_delivered_reply(tmp_path: Path) -> None:
|
|
153
|
+
transcript_path = tmp_path / "transcript.jsonl"
|
|
154
|
+
transcript_path.write_text(
|
|
155
|
+
"\n".join([prompt_line("status"), reply_use_line("a"), result_line("a", True)]),
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
)
|
|
158
|
+
assert second_reply_violation(str(transcript_path), SHIPPED_RULES) is None
|
|
159
|
+
transcript_path.write_text(
|
|
160
|
+
"\n".join([prompt_line("status"), reply_use_line("a"), result_line("a", False)]),
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
)
|
|
163
|
+
assert second_reply_violation(str(transcript_path), SHIPPED_RULES) is not None
|
|
164
|
+
assert second_reply_violation(str(tmp_path / "missing.jsonl"), SHIPPED_RULES) is None
|