claude-dev-env 8.73.1 → 8.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) 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/cde.mjs +1 -0
  10. package/bin/cde.test.mjs +1 -0
  11. package/bin/install.mjs +1 -0
  12. package/bin/install.policy-lint.test.mjs +1 -0
  13. package/bin/install.profile-root.test.mjs +1 -1
  14. package/bin/install.settings-defaults.test.mjs +10 -2
  15. package/bin/install.test.mjs +1 -0
  16. package/bin/merge_settings_defaults.mjs +34 -1
  17. package/bin/merge_settings_defaults.test.mjs +53 -0
  18. package/commands/fix-followups.md +1 -1
  19. package/commands/sr-loop.md +1 -1
  20. package/docs/references/prose-style-enforcement.md +1 -1
  21. package/docs/references/working-style.md +1 -1
  22. package/docs/rule-guides/asd-ste100-language.md +9 -1
  23. package/docs/rule-guides/cleanup-temp-files.md +8 -2
  24. package/docs/rule-guides/destructive-commands.md +13 -3
  25. package/docs/rule-guides/explore-thoroughly.md +9 -1
  26. package/docs/rule-guides/features-start-with-an-eval.md +8 -2
  27. package/docs/rule-guides/filesystem-search.md +7 -1
  28. package/docs/rule-guides/flag-non-breaking-findings.md +3 -0
  29. package/docs/rule-guides/long-horizon-autonomy.md +1 -1
  30. package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
  31. package/docs/rule-guides/no-contrast-framing.md +10 -4
  32. package/docs/rule-guides/prefer-existing-tools.md +2 -2
  33. package/docs/rule-guides/proof-before-pull-request.md +5 -1
  34. package/docs/rule-guides/request-scope.md +5 -1
  35. package/docs/rule-guides/research-mode.md +9 -1
  36. package/docs/rule-guides/session-title.md +7 -1
  37. package/docs/rule-guides/shell-invocation.md +9 -1
  38. package/docs/rule-guides/verify-before-asking.md +7 -1
  39. package/docs/rule-guides/verify-runtime-state.md +7 -1
  40. package/hooks/blocking/code_rules_enforcer.py +16 -6
  41. package/hooks/blocking/code_rules_shared.py +23 -4
  42. package/hooks/blocking/haiku_spawn_slim_gate.py +96 -0
  43. package/hooks/blocking/reply_length_gate.py +74 -14
  44. package/hooks/blocking/state_description_blocker.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
  46. package/hooks/blocking/test_code_rules_shared.py +33 -0
  47. package/hooks/blocking/test_haiku_spawn_slim_gate.py +65 -0
  48. package/hooks/blocking/test_reply_length_gate.py +184 -5
  49. package/hooks/blocking/test_state_description_blocker.py +1 -1
  50. package/hooks/features/conduct-gates.md +3 -6
  51. package/hooks/features/spawn-routing.md +5 -1
  52. package/hooks/followup_ledger.py +52 -2
  53. package/hooks/hooks.json +11 -6
  54. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
  55. package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +29 -0
  56. package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
  57. package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
  58. package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
  59. package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
  60. package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
  61. package/hooks/hooks_constants/windows_filesystem.py +51 -0
  62. package/hooks/session/session_env_cleanup.py +4 -30
  63. package/hooks/test_followup_ledger.py +58 -0
  64. package/hooks/test_visual_reply_rules.py +164 -0
  65. package/hooks/visual_reply_rules.py +268 -0
  66. package/hooks/workflow/test_auto_formatter.py +4 -25
  67. package/package.json +1 -1
  68. package/rules/index.md +30 -0
  69. package/rules/visual-reply-rules.json +41 -0
  70. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  71. package/scripts/conftest.py +13 -5
  72. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
  73. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  74. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
  75. package/scripts/followup_cli.py +25 -1
  76. package/scripts/policy_lint/adapter_configuration.py +0 -5
  77. package/scripts/policy_lint/config/constants.py +1 -1
  78. package/scripts/repository_checks/config/constants.py +6 -0
  79. package/scripts/repository_checks/followup_ledger_duplicates.py +55 -0
  80. package/scripts/repository_checks/runner.py +7 -0
  81. package/scripts/test_active_capability_references.py +0 -5
  82. package/scripts/test_broker_state_isolation.py +3 -3
  83. package/scripts/test_digest_check.py +0 -5
  84. package/scripts/test_durable_post_lint.py +1 -5
  85. package/scripts/test_followup_cli.py +18 -6
  86. package/scripts/test_resolve_worker_spawn.py +5 -10
  87. package/scripts/test_setup_project_paths.py +0 -12
  88. package/scripts/test_shared_tree_paths.py +2 -6
  89. package/scripts/test_validate_instruction_pairs.py +0 -7
  90. package/scripts/test_verify_installable_package.py +0 -5
  91. package/scripts/tests/test_contrast_framing.py +3 -3
  92. package/scripts/tests/test_followup_ledger_duplicates.py +58 -0
  93. package/scripts/tests/test_repository_policy.py +2 -0
  94. package/scripts/tests/test_rule_index_shape.py +59 -5
  95. package/scripts/tests/test_rule_load_scopes.py +1 -16
  96. package/settings.json +9 -0
  97. package/system-prompts/software-engineer.xml +4 -4
  98. package/hooks/blocking/artifact_look_gate.py +0 -223
  99. package/hooks/blocking/test_artifact_look_gate.py +0 -153
  100. package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
  101. package/rules/asd-ste100-language.md +0 -9
  102. package/rules/cleanup-temp-files.md +0 -9
  103. package/rules/destructive-commands.md +0 -11
  104. package/rules/explore-thoroughly.md +0 -9
  105. package/rules/features-start-with-an-eval.md +0 -9
  106. package/rules/filesystem-search.md +0 -9
  107. package/rules/memory-stores-durable-facts.md +0 -9
  108. package/rules/no-contrast-framing.md +0 -9
  109. package/rules/proof-before-pull-request.md +0 -7
  110. package/rules/request-scope.md +0 -7
  111. package/rules/research-mode.md +0 -9
  112. package/rules/session-title.md +0 -9
  113. package/rules/shell-invocation.md +0 -9
  114. package/rules/skill-pointers.md +0 -3
  115. package/rules/verify-before-asking.md +0 -9
  116. package/rules/verify-runtime-state.md +0 -9
@@ -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:
@@ -12,6 +12,8 @@ if _hooks_directory not in sys.path:
12
12
  from followup_ledger import (
13
13
  FollowupFinding,
14
14
  all_recorded_findings,
15
+ deduplicate_ledger,
16
+ deduplicated_lines,
15
17
  followup_ledger_path,
16
18
  head_commit,
17
19
  record_followup_finding,
@@ -187,3 +189,59 @@ def test_head_commit_reads_a_detached_revision(tmp_path: Path) -> None:
187
189
 
188
190
  def test_head_commit_is_empty_outside_a_repository(tmp_path: Path) -> None:
189
191
  assert head_commit(tmp_path) == ""
192
+
193
+
194
+ def test_deduplicated_lines_keeps_the_first_of_each_exact_line_in_order() -> None:
195
+ assert deduplicated_lines(["a", "b", "a", "c", "b"]) == ["a", "b", "c"]
196
+
197
+
198
+ def test_record_followup_finding_removes_lines_a_union_merge_repeated(
199
+ tmp_path: Path,
200
+ ) -> None:
201
+ ledger_path = followup_ledger_path(tmp_path)
202
+ ledger_path.parent.mkdir(parents=True)
203
+ first_line = '{"rule_id": "r1", "file_path": "a.py", "message": "m1"}'
204
+ second_line = '{"rule_id": "r2", "file_path": "b.py", "message": "m2"}'
205
+ ledger_path.write_text(
206
+ f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
207
+ )
208
+
209
+ record_followup_finding(tmp_path, FollowupFinding("r3", "c.py", "m3", "r3"))
210
+
211
+ all_ledger_lines = ledger_path.read_text(encoding="utf-8").splitlines()
212
+ assert all_ledger_lines[:2] == [first_line, second_line]
213
+ assert [json.loads(each_line)["rule_id"] for each_line in all_ledger_lines] == [
214
+ "r1",
215
+ "r2",
216
+ "r3",
217
+ ]
218
+
219
+
220
+ def test_record_followup_finding_removes_a_repeat_of_an_already_recorded_finding(
221
+ tmp_path: Path,
222
+ ) -> None:
223
+ finding = FollowupFinding("r1", "a.py", "m1", "r1")
224
+ record_followup_finding(tmp_path, finding)
225
+ ledger_path = followup_ledger_path(tmp_path)
226
+ recorded_text = ledger_path.read_text(encoding="utf-8")
227
+ ledger_path.write_text(recorded_text * 2, encoding="utf-8")
228
+
229
+ record_followup_finding(tmp_path, finding)
230
+
231
+ assert ledger_path.read_text(encoding="utf-8") == recorded_text
232
+
233
+
234
+ def test_deduplicate_ledger_reports_how_many_lines_it_removed(tmp_path: Path) -> None:
235
+ ledger_path = followup_ledger_path(tmp_path)
236
+ ledger_path.parent.mkdir(parents=True)
237
+ ledger_path.write_text("x\ny\nx\nx\n", encoding="utf-8")
238
+
239
+ removed_count = deduplicate_ledger(tmp_path)
240
+
241
+ assert removed_count == 2
242
+ assert ledger_path.read_text(encoding="utf-8") == "x\ny\n"
243
+
244
+
245
+ def test_deduplicate_ledger_leaves_an_absent_ledger_absent(tmp_path: Path) -> None:
246
+ assert deduplicate_ledger(tmp_path) == 0
247
+ assert not followup_ledger_path(tmp_path).exists()
@@ -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