claude-dev-env 8.106.1 → 8.106.3
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/bin/install-constants.mjs +0 -5
- package/bin/install.cursor-rules.test.mjs +11 -6
- package/bin/install.mjs +3 -2
- package/bin/install.test.mjs +1 -0
- package/docs/codex-compatibility.md +2 -2
- package/hooks/blocking/reply_length_gate.py +229 -106
- package/hooks/blocking/test_reply_length_gate.py +77 -0
- package/hooks/features/README.md +1 -1
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/features/lifecycle-cleanup.md +2 -2
- package/hooks/features/observability.md +1 -5
- package/hooks/features/session-context.md +4 -4
- package/hooks/hooks.json +1 -16
- package/hooks/hooks_constants/hook_specific_output_keys.py +2 -0
- package/hooks/hooks_constants/nested_project_hooks_constants.py +3 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +2 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -23
- package/hooks/hooks_constants/test_hook_specific_output_keys.py +1 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +1 -0
- package/hooks/lifecycle/nested_project_hooks.py +52 -12
- package/hooks/lifecycle/test_nested_project_hooks.py +81 -0
- package/hooks/session/skill_loaded_reminder.py +2 -52
- package/hooks/session/test_skill_loaded_reminder.py +22 -85
- package/hooks/test_visual_reply_rules.py +12 -0
- package/hooks/visual_reply_rules.py +29 -12
- package/package.json +1 -1
- package/hooks/workflow/investigation_tracker_reset.py +0 -48
- package/hooks/workflow/test_investigation_tracker_reset.py +0 -90
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for skill_loaded_reminder. Checks restoration after compaction and
|
|
1
|
+
"""Tests for skill_loaded_reminder. Checks restoration after compaction and spawn pass-through."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
@@ -76,10 +76,6 @@ def _run_main(hook_payload: object) -> str:
|
|
|
76
76
|
return captured_stdout.getvalue()
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def _agent_call(tool_input: dict[str, object], tool_name: str = "Agent") -> dict[str, object]:
|
|
80
|
-
return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
81
|
-
|
|
82
|
-
|
|
83
79
|
def _user_turn(transcript_path: Path) -> dict[str, object]:
|
|
84
80
|
return {"hook_event_name": "UserPromptSubmit", "transcript_path": str(transcript_path)}
|
|
85
81
|
|
|
@@ -90,85 +86,28 @@ def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
|
90
86
|
return transcript_path
|
|
91
87
|
|
|
92
88
|
|
|
93
|
-
class
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"prompt": "List every caller of main.",
|
|
102
|
-
}
|
|
103
|
-
)
|
|
104
|
-
)
|
|
105
|
-
)
|
|
106
|
-
hook_output = emitted["hookSpecificOutput"]
|
|
107
|
-
rewritten_input = hook_output["updatedInput"]
|
|
108
|
-
assert hook_output["permissionDecision"] == "allow"
|
|
109
|
-
assert rewritten_input["prompt"].startswith(
|
|
110
|
-
"Before any other work, invoke the poteto-mode skill with the Skill tool."
|
|
111
|
-
)
|
|
112
|
-
assert rewritten_input["prompt"].endswith("\n\nList every caller of main.")
|
|
113
|
-
assert rewritten_input["subagent_type"] == "Explore"
|
|
114
|
-
assert rewritten_input["description"] == "Find callers"
|
|
115
|
-
|
|
116
|
-
def test_the_legacy_task_tool_name_is_rewritten_too(self) -> None:
|
|
117
|
-
emitted = json.loads(_run_main(_agent_call({"prompt": "Go."}, tool_name="Task")))
|
|
118
|
-
assert "poteto-mode" in emitted["hookSpecificOutput"]["updatedInput"]["prompt"]
|
|
119
|
-
|
|
120
|
-
def test_a_prompt_that_already_names_the_skill_is_left_alone(self) -> None:
|
|
121
|
-
assert _run_main(_agent_call({"prompt": "Invoke poteto-mode first. Then go."})) == ""
|
|
89
|
+
class TestRegistration:
|
|
90
|
+
def test_a_spawn_payload_from_a_stale_registration_prints_nothing(self) -> None:
|
|
91
|
+
stale_spawn_payload = {
|
|
92
|
+
"hook_event_name": "PreToolUse",
|
|
93
|
+
"tool_name": "Agent",
|
|
94
|
+
"tool_input": {"prompt": "Reply leaf."},
|
|
95
|
+
}
|
|
96
|
+
assert _run_main(stale_spawn_payload) == ""
|
|
122
97
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
98
|
+
def test_no_pre_tool_use_group_runs_the_reminder(self) -> None:
|
|
99
|
+
shipped_hooks = json.loads(
|
|
100
|
+
(Path(__file__).resolve().parents[1] / "hooks.json").read_text(encoding="utf-8")
|
|
126
101
|
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
assert rewritten_input is not None
|
|
138
|
-
assert rewritten_input["model"] == "sonnet"
|
|
139
|
-
assert rewritten_input["run_in_background"] is True
|
|
140
|
-
|
|
141
|
-
def test_an_input_with_no_prompt_is_left_alone(self) -> None:
|
|
142
|
-
assert reminder.subagent_input_with_poteto_mode("Agent", {"description": "Go"}) is None
|
|
143
|
-
|
|
144
|
-
def test_a_codex_spawn_input_with_only_items_is_left_alone(self) -> None:
|
|
145
|
-
assert (
|
|
146
|
-
reminder.subagent_input_with_poteto_mode(
|
|
147
|
-
"spawn_agent", {"items": [{"type": "text", "text": "Go."}]}
|
|
148
|
-
)
|
|
149
|
-
is None
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
class TestCodexSpawn:
|
|
154
|
-
def test_a_codex_spawn_message_opens_with_the_skill_mention(self) -> None:
|
|
155
|
-
emitted = json.loads(
|
|
156
|
-
_run_main(
|
|
157
|
-
_agent_call(
|
|
158
|
-
{"message": "Fix the failing test.", "model": "gpt-6-luna"},
|
|
159
|
-
tool_name="spawn_agent",
|
|
160
|
-
)
|
|
161
|
-
)
|
|
162
|
-
)
|
|
163
|
-
rewritten_input = emitted["hookSpecificOutput"]["updatedInput"]
|
|
164
|
-
assert rewritten_input["message"] == "$poteto-mode\n\nFix the failing test."
|
|
165
|
-
assert rewritten_input["model"] == "gpt-6-luna"
|
|
166
|
-
assert "prompt" not in rewritten_input
|
|
167
|
-
|
|
168
|
-
def test_a_codex_spawn_message_that_already_mentions_the_skill_is_left_alone(self) -> None:
|
|
169
|
-
assert (
|
|
170
|
-
_run_main(_agent_call({"message": "$poteto-mode\n\nFix it."}, tool_name="spawn_agent"))
|
|
171
|
-
== ""
|
|
102
|
+
all_pre_tool_use_commands = [
|
|
103
|
+
each_hook.get("command", "")
|
|
104
|
+
for each_group in shipped_hooks["hooks"]["PreToolUse"]
|
|
105
|
+
for each_hook in each_group["hooks"]
|
|
106
|
+
]
|
|
107
|
+
assert all_pre_tool_use_commands
|
|
108
|
+
assert not any(
|
|
109
|
+
"skill_loaded_reminder.py" in each_command
|
|
110
|
+
for each_command in all_pre_tool_use_commands
|
|
172
111
|
)
|
|
173
112
|
|
|
174
113
|
|
|
@@ -353,9 +292,7 @@ class TestReminderFor:
|
|
|
353
292
|
assert hook_output["hookEventName"] == "SubagentStart"
|
|
354
293
|
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
355
294
|
|
|
356
|
-
def
|
|
357
|
-
self,
|
|
358
|
-
) -> None:
|
|
295
|
+
def test_an_agent_tool_helper_starting_prints_nothing(self) -> None:
|
|
359
296
|
assert (
|
|
360
297
|
_run_main({"hook_event_name": "SubagentStart", "agent_type": "general-purpose"}) == ""
|
|
361
298
|
)
|
|
@@ -72,6 +72,18 @@ def test_abbreviation_violation_flags_a_lowercase_abbreviation_and_a_tracker_num
|
|
|
72
72
|
assert 'Tracker number "card 12"' in str(abbreviation_violation("See card 12.", SHIPPED_RULES))
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_abbreviation_violation_names_every_abbreviation_and_tracker_number_on_its_own_line() -> None:
|
|
76
|
+
assert SHIPPED_RULES is not None
|
|
77
|
+
violation = str(abbreviation_violation("The UI and the API, e.g. card 12 and card 13.", SHIPPED_RULES))
|
|
78
|
+
all_lines = violation.splitlines()
|
|
79
|
+
assert len(all_lines) == 5
|
|
80
|
+
assert 'Abbreviation "UI"' in all_lines[0]
|
|
81
|
+
assert 'Abbreviation "API"' in all_lines[1]
|
|
82
|
+
assert 'Abbreviation "e.g."' in all_lines[2]
|
|
83
|
+
assert 'Tracker number "card 12"' in all_lines[3]
|
|
84
|
+
assert 'Tracker number "card 13"' in all_lines[4]
|
|
85
|
+
|
|
86
|
+
|
|
75
87
|
def test_widget_anchor_violation_reads_only_widget_calls() -> None:
|
|
76
88
|
anchor_input = {"widget_code": '<a href="https://example.com">page</a>'}
|
|
77
89
|
assert widget_anchor_violation("mcp__hearthbot__post_widget", anchor_input) is not None
|
|
@@ -19,6 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from json_file_reader import read_json_object
|
|
20
20
|
from hooks_constants.visual_reply_rules_constants import (
|
|
21
21
|
ABBREVIATION_MESSAGE,
|
|
22
|
+
VIOLATION_LINE_SEPARATOR,
|
|
22
23
|
ABBREVIATIONS_RULE_KEY,
|
|
23
24
|
ALL_REPLY_TOOL_NAME_SUFFIXES,
|
|
24
25
|
ALL_VISUAL_TOOL_NAME_SUFFIXES,
|
|
@@ -140,19 +141,35 @@ def mode_enabled(claude_home: Path) -> bool:
|
|
|
140
141
|
|
|
141
142
|
|
|
142
143
|
def abbreviation_violation(prose_text: str, rules: VisualReplyRules) -> str | None:
|
|
143
|
-
"""Return the deny reason
|
|
144
|
+
"""Return the deny reason naming each abbreviation and tracker number in prose with links and code removed.
|
|
145
|
+
|
|
146
|
+
Each one gets its own line, so one refusal names them all.
|
|
147
|
+
"""
|
|
144
148
|
reminder = rules.reminder(ABBREVIATIONS_RULE_KEY)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
149
|
+
all_abbreviations = [
|
|
150
|
+
each_match.group(0)
|
|
151
|
+
for each_match in CAPITALIZED_ABBREVIATION_PATTERN.finditer(prose_text)
|
|
152
|
+
if each_match.group(0) not in rules.all_allowed_capitalized_words
|
|
153
|
+
] + [
|
|
154
|
+
each_abbreviation
|
|
155
|
+
for each_abbreviation in rules.all_lowercase_abbreviations
|
|
156
|
+
if re.search(
|
|
157
|
+
LOWERCASE_ABBREVIATION_TEMPLATE.format(abbreviation=re.escape(each_abbreviation)),
|
|
158
|
+
prose_text,
|
|
159
|
+
re.IGNORECASE,
|
|
160
|
+
)
|
|
161
|
+
]
|
|
162
|
+
all_violations = [
|
|
163
|
+
*(
|
|
164
|
+
ABBREVIATION_MESSAGE.format(abbreviation=each_abbreviation, reminder=reminder)
|
|
165
|
+
for each_abbreviation in dict.fromkeys(all_abbreviations)
|
|
166
|
+
),
|
|
167
|
+
*(
|
|
168
|
+
TRACKER_NUMBER_MESSAGE.format(reference=each_match.group(0), reminder=reminder)
|
|
169
|
+
for each_match in TRACKER_NUMBER_PATTERN.finditer(prose_text)
|
|
170
|
+
),
|
|
171
|
+
]
|
|
172
|
+
return VIOLATION_LINE_SEPARATOR.join(all_violations) if all_violations else None
|
|
156
173
|
|
|
157
174
|
|
|
158
175
|
def widget_anchor_violation(tool_name: str, all_tool_input: dict[str, object]) -> str | None:
|
package/package.json
CHANGED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PostToolUse hook — resets the serial investigation tracker when delegation occurs.
|
|
3
|
-
|
|
4
|
-
Clears the investigation timestamp tracker when the lead session delegates work
|
|
5
|
-
via Agent, Task, or TeamCreate tools. This allows the lead to resume limited
|
|
6
|
-
diagnostic calls after properly delegating.
|
|
7
|
-
|
|
8
|
-
Companion: blocking/serial-investigation-blocker.py (PreToolUse on Read|Bash|Grep)
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import sys
|
|
14
|
-
|
|
15
|
-
TRACKER_STATE_PATH = os.path.join(
|
|
16
|
-
os.path.expanduser("~"), ".claude", "runtime", "investigation-tracker.json"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
DELEGATION_TOOLS = frozenset({"Agent", "Task", "TeamCreate"})
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def clear_investigation_tracker() -> None:
|
|
23
|
-
try:
|
|
24
|
-
os.remove(TRACKER_STATE_PATH)
|
|
25
|
-
except FileNotFoundError:
|
|
26
|
-
pass
|
|
27
|
-
except OSError:
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def main() -> None:
|
|
32
|
-
try:
|
|
33
|
-
hook_input = json.load(sys.stdin)
|
|
34
|
-
except json.JSONDecodeError:
|
|
35
|
-
sys.exit(0)
|
|
36
|
-
if not isinstance(hook_input, dict):
|
|
37
|
-
sys.exit(0)
|
|
38
|
-
|
|
39
|
-
tool_name = hook_input.get("tool_name", "")
|
|
40
|
-
if tool_name not in DELEGATION_TOOLS:
|
|
41
|
-
sys.exit(0)
|
|
42
|
-
|
|
43
|
-
clear_investigation_tracker()
|
|
44
|
-
sys.exit(0)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if __name__ == "__main__":
|
|
48
|
-
main()
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for investigation tracker reset."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
import io
|
|
7
|
-
import json
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from unittest import mock
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
HOOK_DIRECTORY = Path(__file__).parent
|
|
15
|
-
HOOK_SPEC = importlib.util.spec_from_file_location(
|
|
16
|
-
"investigation_tracker_reset",
|
|
17
|
-
HOOK_DIRECTORY / "investigation_tracker_reset.py",
|
|
18
|
-
)
|
|
19
|
-
assert HOOK_SPEC is not None
|
|
20
|
-
assert HOOK_SPEC.loader is not None
|
|
21
|
-
HOOK_MODULE = importlib.util.module_from_spec(HOOK_SPEC)
|
|
22
|
-
HOOK_SPEC.loader.exec_module(HOOK_MODULE)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _run_main(input_text: str) -> int | None:
|
|
26
|
-
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
27
|
-
try:
|
|
28
|
-
HOOK_MODULE.main()
|
|
29
|
-
except SystemExit as system_exit:
|
|
30
|
-
if system_exit.code not in (None, 0):
|
|
31
|
-
raise
|
|
32
|
-
return system_exit.code if isinstance(system_exit.code, int) else None
|
|
33
|
-
return None
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@pytest.mark.parametrize("delegation_tool", ["Agent", "Task", "TeamCreate"])
|
|
37
|
-
def test_main_removes_tracker_after_delegation(
|
|
38
|
-
tmp_path: Path,
|
|
39
|
-
delegation_tool: str,
|
|
40
|
-
) -> None:
|
|
41
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
42
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
43
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
44
|
-
|
|
45
|
-
exit_status = _run_main(json.dumps({"tool_name": delegation_tool}))
|
|
46
|
-
|
|
47
|
-
assert exit_status in (None, 0)
|
|
48
|
-
assert not tracker_path.exists()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@pytest.mark.parametrize("unrelated_tool", ["Read", "Bash", "Grep"])
|
|
52
|
-
def test_main_keeps_tracker_for_unrelated_tool(
|
|
53
|
-
tmp_path: Path,
|
|
54
|
-
unrelated_tool: str,
|
|
55
|
-
) -> None:
|
|
56
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
57
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
58
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
59
|
-
|
|
60
|
-
exit_status = _run_main(json.dumps({"tool_name": unrelated_tool}))
|
|
61
|
-
|
|
62
|
-
assert exit_status in (None, 0)
|
|
63
|
-
assert tracker_path.exists()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@pytest.mark.parametrize("invalid_input", ["not json", json.dumps([])])
|
|
67
|
-
def test_main_ignores_invalid_input(
|
|
68
|
-
tmp_path: Path,
|
|
69
|
-
invalid_input: str,
|
|
70
|
-
) -> None:
|
|
71
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
72
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
73
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
74
|
-
|
|
75
|
-
exit_status = _run_main(invalid_input)
|
|
76
|
-
|
|
77
|
-
assert exit_status in (None, 0)
|
|
78
|
-
assert tracker_path.exists()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_main_keeps_running_when_tracker_removal_fails(tmp_path: Path) -> None:
|
|
82
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
83
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
84
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
85
|
-
|
|
86
|
-
with mock.patch.object(HOOK_MODULE.os, "remove", side_effect=OSError("locked")):
|
|
87
|
-
exit_status = _run_main(json.dumps({"tool_name": "Agent"}))
|
|
88
|
-
|
|
89
|
-
assert exit_status in (None, 0)
|
|
90
|
-
assert tracker_path.exists()
|