claude-dev-env 1.26.4 → 1.26.5
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.
|
@@ -11,6 +11,9 @@ import os
|
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
13
|
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "config"))
|
|
15
|
+
from messages import USER_FACING_NOTICE
|
|
16
|
+
|
|
14
17
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
15
18
|
|
|
16
19
|
RESEARCH_MODE_SKILL_SEARCH_PATHS = [
|
|
@@ -99,14 +102,19 @@ def main() -> None:
|
|
|
99
102
|
|
|
100
103
|
formatted_term_list = ", ".join(f'"{term}"' for term in found_hedging_terms)
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
resolved_skill_path: str | None = None
|
|
103
106
|
for each_skill_path in RESEARCH_MODE_SKILL_SEARCH_PATHS:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
if os.path.exists(each_skill_path):
|
|
108
|
+
resolved_skill_path = each_skill_path
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
if resolved_skill_path is not None:
|
|
112
|
+
skill_reference = f"under the research-mode constraints defined in:\n\n{resolved_skill_path}"
|
|
113
|
+
else:
|
|
114
|
+
skill_reference = (
|
|
115
|
+
"under research-mode constraints "
|
|
116
|
+
"(no research-mode skill installed; verify with sources or reply 'I don't know')"
|
|
117
|
+
)
|
|
110
118
|
|
|
111
119
|
block_response = {
|
|
112
120
|
"decision": "block",
|
|
@@ -114,12 +122,13 @@ def main() -> None:
|
|
|
114
122
|
f"ANTI-HALLUCINATION GUARDRAIL: Your response contains hedging language: "
|
|
115
123
|
f"{formatted_term_list}. "
|
|
116
124
|
f"These words signal unverified claims. You MUST rewrite your response "
|
|
117
|
-
f"
|
|
118
|
-
f"{research_mode_content}\n\n"
|
|
125
|
+
f"{skill_reference}\n\n"
|
|
119
126
|
f"Do NOT simply remove the hedging word and keep the unverified claim. "
|
|
120
127
|
f"Either VERIFY it with a source or replace it with 'I don't know'.\n\n"
|
|
121
128
|
f"You MUST re-output the complete, revised response with the corrections applied."
|
|
122
129
|
),
|
|
130
|
+
"systemMessage": USER_FACING_NOTICE,
|
|
131
|
+
"suppressOutput": True,
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
print(json.dumps(block_response))
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Tests for hedging_language_blocker hook response shape."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "hedging_language_blocker.py")
|
|
11
|
+
_HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
|
|
12
|
+
_CONFIG_DIR = os.path.join(_HOOKS_DIR, "..", "config")
|
|
13
|
+
if _HOOKS_DIR not in sys.path:
|
|
14
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
15
|
+
if _CONFIG_DIR not in sys.path:
|
|
16
|
+
sys.path.insert(0, _CONFIG_DIR)
|
|
17
|
+
import hedging_language_blocker
|
|
18
|
+
from messages import USER_FACING_NOTICE
|
|
19
|
+
|
|
20
|
+
RESEARCH_MODE_SKILL_BODY_MARKER = "Three anti-hallucination constraints are ALWAYS active."
|
|
21
|
+
HEDGING_MESSAGE = "This is likely correct."
|
|
22
|
+
CLEAN_MESSAGE = "This is verified by the source document."
|
|
23
|
+
EMPTY_MESSAGE = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
|
|
27
|
+
hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
|
|
28
|
+
return subprocess.run(
|
|
29
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
30
|
+
input=hook_input_payload,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
check=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_hook_with_patched_search_paths(
|
|
38
|
+
assistant_message: str,
|
|
39
|
+
search_paths: list[str],
|
|
40
|
+
) -> subprocess.CompletedProcess:
|
|
41
|
+
"""Run the hook with RESEARCH_MODE_SKILL_SEARCH_PATHS overridden via a wrapper script."""
|
|
42
|
+
wrapper_script = (
|
|
43
|
+
"import sys, json, os\n"
|
|
44
|
+
f"sys.path.insert(0, {repr(os.path.dirname(HOOK_SCRIPT_PATH))})\n"
|
|
45
|
+
"import hedging_language_blocker as blocker\n"
|
|
46
|
+
f"blocker.RESEARCH_MODE_SKILL_SEARCH_PATHS = {repr(search_paths)}\n"
|
|
47
|
+
"blocker.main()\n"
|
|
48
|
+
)
|
|
49
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as wrapper_file:
|
|
50
|
+
wrapper_file.write(wrapper_script)
|
|
51
|
+
wrapper_file_path = wrapper_file.name
|
|
52
|
+
|
|
53
|
+
hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
|
|
54
|
+
try:
|
|
55
|
+
completed_process = subprocess.run(
|
|
56
|
+
[sys.executable, wrapper_file_path],
|
|
57
|
+
input=hook_input_payload,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
check=False,
|
|
61
|
+
)
|
|
62
|
+
finally:
|
|
63
|
+
os.unlink(wrapper_file_path)
|
|
64
|
+
return completed_process
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_user_facing_notice_importable_from_config_messages():
|
|
68
|
+
config_messages_path = os.path.join(_CONFIG_DIR, "messages.py")
|
|
69
|
+
specification = importlib.util.spec_from_file_location("messages", config_messages_path)
|
|
70
|
+
module = importlib.util.module_from_spec(specification)
|
|
71
|
+
specification.loader.exec_module(module)
|
|
72
|
+
|
|
73
|
+
assert module.USER_FACING_NOTICE == USER_FACING_NOTICE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_hedging_message_emits_block_with_short_user_notice():
|
|
77
|
+
completed_process = run_hook_with_message(HEDGING_MESSAGE)
|
|
78
|
+
|
|
79
|
+
assert completed_process.returncode == 0
|
|
80
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
81
|
+
|
|
82
|
+
assert parsed_response["decision"] == "block"
|
|
83
|
+
assert parsed_response["systemMessage"] == USER_FACING_NOTICE
|
|
84
|
+
assert parsed_response["suppressOutput"] is True
|
|
85
|
+
assert "likely" in parsed_response["reason"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_hedging_reason_contains_not_installed_notice_when_skill_absent():
|
|
89
|
+
completed_process = run_hook_with_patched_search_paths(
|
|
90
|
+
HEDGING_MESSAGE,
|
|
91
|
+
["/nonexistent/path/one/SKILL.md", "/nonexistent/path/two/SKILL.md"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert completed_process.returncode == 0
|
|
95
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
96
|
+
|
|
97
|
+
assert parsed_response["decision"] == "block"
|
|
98
|
+
assert "no research-mode skill installed" in parsed_response["reason"]
|
|
99
|
+
assert "verify with sources or reply" in parsed_response["reason"]
|
|
100
|
+
assert "SKILL.md" not in parsed_response["reason"]
|
|
101
|
+
assert RESEARCH_MODE_SKILL_BODY_MARKER not in parsed_response["reason"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_hedging_reason_contains_skill_path_when_skill_present():
|
|
105
|
+
with tempfile.TemporaryDirectory() as skill_dir:
|
|
106
|
+
skill_file_path = os.path.join(skill_dir, "SKILL.md")
|
|
107
|
+
with open(skill_file_path, "w") as skill_file:
|
|
108
|
+
skill_file.write("# Research Mode Skill\n")
|
|
109
|
+
|
|
110
|
+
completed_process = run_hook_with_patched_search_paths(
|
|
111
|
+
HEDGING_MESSAGE,
|
|
112
|
+
["/nonexistent/path/SKILL.md", skill_file_path],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert completed_process.returncode == 0
|
|
116
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
117
|
+
|
|
118
|
+
assert parsed_response["decision"] == "block"
|
|
119
|
+
assert "SKILL.md" in parsed_response["reason"]
|
|
120
|
+
assert "no research-mode skill installed" not in parsed_response["reason"]
|
|
121
|
+
assert RESEARCH_MODE_SKILL_BODY_MARKER not in parsed_response["reason"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_clean_message_passes_through_with_no_output():
|
|
125
|
+
completed_process = run_hook_with_message(CLEAN_MESSAGE)
|
|
126
|
+
|
|
127
|
+
assert completed_process.returncode == 0
|
|
128
|
+
assert completed_process.stdout == ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_empty_message_passes_through_with_no_output():
|
|
132
|
+
completed_process = run_hook_with_message(EMPTY_MESSAGE)
|
|
133
|
+
|
|
134
|
+
assert completed_process.returncode == 0
|
|
135
|
+
assert completed_process.stdout == ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# pragma: no-tdd-gate
|