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.
- 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/cde.mjs +1 -0
- package/bin/cde.test.mjs +1 -0
- 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.settings-defaults.test.mjs +10 -2
- package/bin/install.test.mjs +1 -0
- package/bin/merge_settings_defaults.mjs +34 -1
- package/bin/merge_settings_defaults.test.mjs +53 -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/flag-non-breaking-findings.md +3 -0
- 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/haiku_spawn_slim_gate.py +96 -0
- 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_haiku_spawn_slim_gate.py +65 -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/features/spawn-routing.md +5 -1
- package/hooks/followup_ledger.py +52 -2
- package/hooks/hooks.json +11 -6
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/haiku_spawn_slim_gate_constants.py +29 -0
- 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_followup_ledger.py +58 -0
- 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/followup_constants.py +4 -1
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
- package/scripts/followup_cli.py +25 -1
- package/scripts/policy_lint/adapter_configuration.py +0 -5
- package/scripts/policy_lint/config/constants.py +1 -1
- package/scripts/repository_checks/config/constants.py +6 -0
- package/scripts/repository_checks/followup_ledger_duplicates.py +55 -0
- package/scripts/repository_checks/runner.py +7 -0
- 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 +18 -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_followup_ledger_duplicates.py +58 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_rule_index_shape.py +59 -5
- package/scripts/tests/test_rule_load_scopes.py +1 -16
- package/settings.json +9 -0
- 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
|
@@ -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:
|
|
@@ -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
|