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.
Files changed (98) hide show
  1. package/.agents/agents/test_agent_frontmatter.py +23 -23
  2. package/.agents/agents-archived/clean-coder.md +5 -5
  3. package/.agents/skills/correction/playbooks/fix.md +1 -1
  4. package/.agents/skills/eli5/SKILL.md +3 -3
  5. package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
  6. package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
  7. package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
  8. package/_shared/advisor/reference/third-party-bind.md +1 -1
  9. package/bin/install.mjs +1 -0
  10. package/bin/install.policy-lint.test.mjs +1 -0
  11. package/bin/install.profile-root.test.mjs +1 -1
  12. package/bin/install.test.mjs +1 -0
  13. package/commands/fix-followups.md +1 -1
  14. package/commands/sr-loop.md +1 -1
  15. package/docs/references/prose-style-enforcement.md +1 -1
  16. package/docs/references/working-style.md +1 -1
  17. package/docs/rule-guides/asd-ste100-language.md +9 -1
  18. package/docs/rule-guides/cleanup-temp-files.md +8 -2
  19. package/docs/rule-guides/destructive-commands.md +13 -3
  20. package/docs/rule-guides/explore-thoroughly.md +9 -1
  21. package/docs/rule-guides/features-start-with-an-eval.md +8 -2
  22. package/docs/rule-guides/filesystem-search.md +7 -1
  23. package/docs/rule-guides/long-horizon-autonomy.md +1 -1
  24. package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
  25. package/docs/rule-guides/no-contrast-framing.md +10 -4
  26. package/docs/rule-guides/prefer-existing-tools.md +2 -2
  27. package/docs/rule-guides/proof-before-pull-request.md +5 -1
  28. package/docs/rule-guides/request-scope.md +5 -1
  29. package/docs/rule-guides/research-mode.md +9 -1
  30. package/docs/rule-guides/session-title.md +7 -1
  31. package/docs/rule-guides/shell-invocation.md +9 -1
  32. package/docs/rule-guides/verify-before-asking.md +7 -1
  33. package/docs/rule-guides/verify-runtime-state.md +7 -1
  34. package/hooks/blocking/code_rules_enforcer.py +16 -6
  35. package/hooks/blocking/code_rules_shared.py +23 -4
  36. package/hooks/blocking/reply_length_gate.py +74 -14
  37. package/hooks/blocking/state_description_blocker.py +1 -1
  38. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
  39. package/hooks/blocking/test_code_rules_shared.py +33 -0
  40. package/hooks/blocking/test_reply_length_gate.py +184 -5
  41. package/hooks/blocking/test_state_description_blocker.py +1 -1
  42. package/hooks/features/conduct-gates.md +3 -6
  43. package/hooks/hooks.json +1 -6
  44. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
  46. package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
  47. package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
  48. package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
  49. package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
  50. package/hooks/hooks_constants/windows_filesystem.py +51 -0
  51. package/hooks/session/session_env_cleanup.py +4 -30
  52. package/hooks/test_visual_reply_rules.py +164 -0
  53. package/hooks/visual_reply_rules.py +268 -0
  54. package/hooks/workflow/test_auto_formatter.py +4 -25
  55. package/package.json +1 -1
  56. package/rules/index.md +30 -0
  57. package/rules/visual-reply-rules.json +41 -0
  58. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  59. package/scripts/conftest.py +13 -5
  60. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
  62. package/scripts/policy_lint/adapter_configuration.py +0 -5
  63. package/scripts/policy_lint/config/constants.py +0 -1
  64. package/scripts/test__code_review_test_support.py +40 -0
  65. package/scripts/test_active_capability_references.py +0 -5
  66. package/scripts/test_broker_state_isolation.py +3 -3
  67. package/scripts/test_codec_forwarding_test_support.py +28 -0
  68. package/scripts/test_digest_check.py +0 -5
  69. package/scripts/test_durable_post_lint.py +1 -5
  70. package/scripts/test_followup_cli.py +0 -6
  71. package/scripts/test_resolve_worker_spawn.py +5 -10
  72. package/scripts/test_setup_project_paths.py +0 -12
  73. package/scripts/test_shared_tree_paths.py +2 -6
  74. package/scripts/test_validate_instruction_pairs.py +0 -7
  75. package/scripts/test_verify_installable_package.py +0 -5
  76. package/scripts/tests/test_contrast_framing.py +3 -3
  77. package/scripts/tests/test_rule_index_shape.py +59 -5
  78. package/scripts/tests/test_rule_load_scopes.py +1 -16
  79. package/system-prompts/software-engineer.xml +4 -4
  80. package/hooks/blocking/artifact_look_gate.py +0 -223
  81. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  82. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  83. package/rules/asd-ste100-language.md +0 -9
  84. package/rules/cleanup-temp-files.md +0 -9
  85. package/rules/destructive-commands.md +0 -11
  86. package/rules/explore-thoroughly.md +0 -9
  87. package/rules/features-start-with-an-eval.md +0 -9
  88. package/rules/filesystem-search.md +0 -9
  89. package/rules/memory-stores-durable-facts.md +0 -9
  90. package/rules/no-contrast-framing.md +0 -9
  91. package/rules/proof-before-pull-request.md +0 -7
  92. package/rules/request-scope.md +0 -7
  93. package/rules/research-mode.md +0 -9
  94. package/rules/session-title.md +0 -9
  95. package/rules/shell-invocation.md +0 -9
  96. package/rules/skill-pointers.md +0 -3
  97. package/rules/verify-before-asking.md +0 -9
  98. 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, 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.
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 or a plain pull request number. 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 or link reason.
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
- } | ALL_AGENT_HOME_TOOLING_PATTERNS
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 ( # noqa: E402
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
- _force_rmtree(current_session_path)
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
- _force_rmtree(entry_path)
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