claude-dev-env 8.96.8 → 8.98.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/bin/install-pstack-plugin.mjs +31 -4
- package/bin/install-pstack-plugin.test.mjs +23 -0
- package/bin/install.prompt-context.test.mjs +3 -1
- package/bin/install.settings-defaults.test.mjs +4 -3
- package/docs/agents-guidance/asks.md +6 -1
- package/hooks/blocking/open_tasks_stop_gate.py +128 -34
- package/hooks/blocking/open_tasks_transcript.py +112 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
- package/hooks/blocking/python_windows_invocation_gate.py +249 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
- package/hooks/blocking/test_open_tasks_transcript.py +89 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
- package/hooks/blocking/verify_before_commit_gate.py +278 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +10 -2
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
- package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
- package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
- package/hooks/open_task_asks.py +163 -0
- package/hooks/plugin_session_baseline.py +190 -0
- package/hooks/session/open_task_ask_recorder.py +38 -0
- package/hooks/session/plugin_change_warning.py +90 -0
- package/hooks/session/plugin_session_baseline_writer.py +53 -0
- package/hooks/session/test_open_task_ask_recorder.py +57 -0
- package/hooks/session/test_plugin_change_warning.py +216 -0
- package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
- package/hooks/test_context_budget_hooks.py +2 -0
- package/hooks/test_open_task_asks.py +52 -0
- package/hooks/test_plugin_session_baseline.py +98 -0
- package/hooks/test_transcript_skill_scan.py +24 -0
- package/hooks/transcript_skill_scan.py +54 -3
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +2 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Tests for plugin_change_warning: the UserPromptSubmit plugin restart warning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
15
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
16
|
+
if each_sys_path_entry not in sys.path:
|
|
17
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
18
|
+
|
|
19
|
+
import plugin_change_warning
|
|
20
|
+
import plugin_session_baseline
|
|
21
|
+
import plugin_session_baseline_writer
|
|
22
|
+
|
|
23
|
+
SESSION_ID = "session-two"
|
|
24
|
+
DEEPSTACK_ID = "deepstack@deepstack"
|
|
25
|
+
PSTACK_ID = "pstack@pstack-claude"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _plugin_entry(version: str, install_path: str, last_updated: str) -> dict[str, str]:
|
|
29
|
+
return {
|
|
30
|
+
"scope": "user",
|
|
31
|
+
"installPath": install_path,
|
|
32
|
+
"version": version,
|
|
33
|
+
"installedAt": "2026-10-21T19:00:00.000Z",
|
|
34
|
+
"lastUpdated": last_updated,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_installed_plugins(
|
|
39
|
+
config_directory: Path, all_plugins: dict[str, list[dict[str, str]]]
|
|
40
|
+
) -> None:
|
|
41
|
+
plugins_directory = config_directory / "plugins"
|
|
42
|
+
plugins_directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
(plugins_directory / "installed_plugins.json").write_text(
|
|
44
|
+
json.dumps({"version": 2, "plugins": all_plugins}), encoding="utf-8"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_main(main_function: object, payload: dict[str, str]) -> str:
|
|
49
|
+
payload_bytes = json.dumps(payload).encode("utf-8")
|
|
50
|
+
captured_stdout = io.StringIO()
|
|
51
|
+
with (
|
|
52
|
+
patch("sys.stdin", io.TextIOWrapper(io.BytesIO(payload_bytes))),
|
|
53
|
+
patch("sys.stdout", captured_stdout),
|
|
54
|
+
):
|
|
55
|
+
main_function()
|
|
56
|
+
return captured_stdout.getvalue()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def start_session(source: str) -> None:
|
|
60
|
+
_run_main(
|
|
61
|
+
plugin_session_baseline_writer.main,
|
|
62
|
+
{"session_id": SESSION_ID, "hook_event_name": "SessionStart", "source": source},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def submit_prompt() -> dict[str, object] | None:
|
|
67
|
+
hook_output = _run_main(
|
|
68
|
+
plugin_change_warning.main,
|
|
69
|
+
{
|
|
70
|
+
"session_id": SESSION_ID,
|
|
71
|
+
"hook_event_name": "UserPromptSubmit",
|
|
72
|
+
"prompt": "run /deepstack-mode",
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
return json.loads(hook_output) if hook_output else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
80
|
+
claude_config_directory = tmp_path / "config"
|
|
81
|
+
state_directory = tmp_path / "state"
|
|
82
|
+
state_directory.mkdir()
|
|
83
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config_directory))
|
|
84
|
+
monkeypatch.setattr(
|
|
85
|
+
plugin_session_baseline.tempfile, "gettempdir", lambda: str(state_directory)
|
|
86
|
+
)
|
|
87
|
+
return claude_config_directory
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _warning_text(hook_output: dict[str, object] | None) -> str:
|
|
91
|
+
assert hook_output is not None
|
|
92
|
+
system_message = hook_output["systemMessage"]
|
|
93
|
+
assert isinstance(system_message, str)
|
|
94
|
+
hook_specific_output = hook_output["hookSpecificOutput"]
|
|
95
|
+
assert isinstance(hook_specific_output, dict)
|
|
96
|
+
assert hook_specific_output["hookEventName"] == "UserPromptSubmit"
|
|
97
|
+
assert hook_specific_output["additionalContext"] == system_message
|
|
98
|
+
return system_message
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_a_changed_version_should_warn_once(config_directory: Path) -> None:
|
|
102
|
+
write_installed_plugins(
|
|
103
|
+
config_directory,
|
|
104
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
105
|
+
)
|
|
106
|
+
start_session("startup")
|
|
107
|
+
write_installed_plugins(
|
|
108
|
+
config_directory,
|
|
109
|
+
{PSTACK_ID: [_plugin_entry("1.1.0", "/cache/pstack/1.1.0", "b")]},
|
|
110
|
+
)
|
|
111
|
+
warning_text = _warning_text(submit_prompt())
|
|
112
|
+
assert PSTACK_ID in warning_text
|
|
113
|
+
assert "Restart Claude Code" in warning_text
|
|
114
|
+
assert submit_prompt() is None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_a_plugin_new_since_start_should_warn(config_directory: Path) -> None:
|
|
118
|
+
write_installed_plugins(
|
|
119
|
+
config_directory,
|
|
120
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
121
|
+
)
|
|
122
|
+
start_session("startup")
|
|
123
|
+
write_installed_plugins(
|
|
124
|
+
config_directory,
|
|
125
|
+
{
|
|
126
|
+
PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
|
|
127
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "b")],
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
warning_text = _warning_text(submit_prompt())
|
|
131
|
+
assert DEEPSTACK_ID in warning_text
|
|
132
|
+
assert PSTACK_ID not in warning_text
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_a_changed_install_path_should_warn(config_directory: Path) -> None:
|
|
136
|
+
write_installed_plugins(
|
|
137
|
+
config_directory,
|
|
138
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/old", "a")]},
|
|
139
|
+
)
|
|
140
|
+
start_session("startup")
|
|
141
|
+
write_installed_plugins(
|
|
142
|
+
config_directory,
|
|
143
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/new", "a")]},
|
|
144
|
+
)
|
|
145
|
+
assert PSTACK_ID in _warning_text(submit_prompt())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_a_bare_last_updated_refresh_should_stay_quiet(config_directory: Path) -> None:
|
|
149
|
+
write_installed_plugins(
|
|
150
|
+
config_directory,
|
|
151
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
152
|
+
)
|
|
153
|
+
start_session("startup")
|
|
154
|
+
write_installed_plugins(
|
|
155
|
+
config_directory,
|
|
156
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "later")]},
|
|
157
|
+
)
|
|
158
|
+
assert submit_prompt() is None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_an_install_then_compact_then_prompt_should_still_warn(
|
|
162
|
+
config_directory: Path,
|
|
163
|
+
) -> None:
|
|
164
|
+
write_installed_plugins(
|
|
165
|
+
config_directory,
|
|
166
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
167
|
+
)
|
|
168
|
+
start_session("startup")
|
|
169
|
+
write_installed_plugins(
|
|
170
|
+
config_directory,
|
|
171
|
+
{
|
|
172
|
+
PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
|
|
173
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "b")],
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
start_session("compact")
|
|
177
|
+
assert DEEPSTACK_ID in _warning_text(submit_prompt())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_a_second_plugin_change_should_warn_only_for_that_plugin(
|
|
181
|
+
config_directory: Path,
|
|
182
|
+
) -> None:
|
|
183
|
+
write_installed_plugins(
|
|
184
|
+
config_directory,
|
|
185
|
+
{
|
|
186
|
+
PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")],
|
|
187
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "a")],
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
start_session("startup")
|
|
191
|
+
write_installed_plugins(
|
|
192
|
+
config_directory,
|
|
193
|
+
{
|
|
194
|
+
PSTACK_ID: [_plugin_entry("1.1.0", "/cache/pstack/1.1.0", "b")],
|
|
195
|
+
DEEPSTACK_ID: [_plugin_entry("3.0.0", "/cache/deepstack/3.0.0", "a")],
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
assert PSTACK_ID in _warning_text(submit_prompt())
|
|
199
|
+
write_installed_plugins(
|
|
200
|
+
config_directory,
|
|
201
|
+
{
|
|
202
|
+
PSTACK_ID: [_plugin_entry("1.2.0", "/cache/pstack/1.2.0", "c")],
|
|
203
|
+
DEEPSTACK_ID: [_plugin_entry("3.1.0", "/cache/deepstack/3.1.0", "c")],
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
second_warning_text = _warning_text(submit_prompt())
|
|
207
|
+
assert DEEPSTACK_ID in second_warning_text
|
|
208
|
+
assert PSTACK_ID not in second_warning_text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_a_session_without_a_baseline_should_stay_quiet(config_directory: Path) -> None:
|
|
212
|
+
write_installed_plugins(
|
|
213
|
+
config_directory,
|
|
214
|
+
{PSTACK_ID: [_plugin_entry("1.0.0", "/cache/pstack/1.0.0", "a")]},
|
|
215
|
+
)
|
|
216
|
+
assert submit_prompt() is None
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests for plugin_session_baseline_writer: the SessionStart plugin baseline hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
15
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
16
|
+
if each_sys_path_entry not in sys.path:
|
|
17
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
18
|
+
|
|
19
|
+
import plugin_session_baseline
|
|
20
|
+
import plugin_session_baseline_writer
|
|
21
|
+
|
|
22
|
+
SESSION_ID = "session-one"
|
|
23
|
+
PLUGIN_ID = "deepstack@deepstack"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _plugin_entry(version: str, install_path: str, last_updated: str) -> dict[str, str]:
|
|
27
|
+
return {
|
|
28
|
+
"scope": "user",
|
|
29
|
+
"installPath": install_path,
|
|
30
|
+
"version": version,
|
|
31
|
+
"installedAt": "2026-10-21T19:00:00.000Z",
|
|
32
|
+
"lastUpdated": last_updated,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_installed_plugins(
|
|
37
|
+
config_directory: Path, all_plugins: dict[str, list[dict[str, str]]]
|
|
38
|
+
) -> None:
|
|
39
|
+
plugins_directory = config_directory / "plugins"
|
|
40
|
+
plugins_directory.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
(plugins_directory / "installed_plugins.json").write_text(
|
|
42
|
+
json.dumps({"version": 2, "plugins": all_plugins}), encoding="utf-8"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_writer(session_id: str, source: str) -> str:
|
|
47
|
+
payload_text = json.dumps(
|
|
48
|
+
{"session_id": session_id, "hook_event_name": "SessionStart", "source": source}
|
|
49
|
+
)
|
|
50
|
+
captured_stdout = io.StringIO()
|
|
51
|
+
with (
|
|
52
|
+
patch("sys.stdin", io.TextIOWrapper(io.BytesIO(payload_text.encode("utf-8")))),
|
|
53
|
+
patch("sys.stdout", captured_stdout),
|
|
54
|
+
):
|
|
55
|
+
plugin_session_baseline_writer.main()
|
|
56
|
+
return captured_stdout.getvalue()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def config_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
61
|
+
claude_config_directory = tmp_path / "config"
|
|
62
|
+
state_directory = tmp_path / "state"
|
|
63
|
+
state_directory.mkdir()
|
|
64
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config_directory))
|
|
65
|
+
monkeypatch.setattr(
|
|
66
|
+
plugin_session_baseline.tempfile, "gettempdir", lambda: str(state_directory)
|
|
67
|
+
)
|
|
68
|
+
return claude_config_directory
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_startup_should_record_each_plugin_version_and_install_path(
|
|
72
|
+
config_directory: Path,
|
|
73
|
+
) -> None:
|
|
74
|
+
write_installed_plugins(
|
|
75
|
+
config_directory,
|
|
76
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
77
|
+
)
|
|
78
|
+
assert run_writer(SESSION_ID, "startup") == ""
|
|
79
|
+
baseline_record = plugin_session_baseline.read_baseline_record(SESSION_ID)
|
|
80
|
+
assert baseline_record == {
|
|
81
|
+
"plugins": {
|
|
82
|
+
PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/deepstack/1.0.0"}]
|
|
83
|
+
},
|
|
84
|
+
"warned": [],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("source", ["resume", "clear", "compact"])
|
|
89
|
+
def test_a_later_source_should_leave_an_existing_baseline_alone(
|
|
90
|
+
config_directory: Path, source: str
|
|
91
|
+
) -> None:
|
|
92
|
+
write_installed_plugins(
|
|
93
|
+
config_directory,
|
|
94
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
95
|
+
)
|
|
96
|
+
run_writer(SESSION_ID, "startup")
|
|
97
|
+
baseline_path = plugin_session_baseline.baseline_record_path(SESSION_ID)
|
|
98
|
+
baseline_text = baseline_path.read_text(encoding="utf-8")
|
|
99
|
+
write_installed_plugins(
|
|
100
|
+
config_directory,
|
|
101
|
+
{PLUGIN_ID: [_plugin_entry("2.0.0", "/cache/deepstack/2.0.0", "b")]},
|
|
102
|
+
)
|
|
103
|
+
run_writer(SESSION_ID, source)
|
|
104
|
+
assert baseline_path.read_text(encoding="utf-8") == baseline_text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_resume_without_a_baseline_should_write_one(config_directory: Path) -> None:
|
|
108
|
+
write_installed_plugins(
|
|
109
|
+
config_directory,
|
|
110
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
111
|
+
)
|
|
112
|
+
run_writer(SESSION_ID, "resume")
|
|
113
|
+
baseline_record = plugin_session_baseline.read_baseline_record(SESSION_ID)
|
|
114
|
+
assert baseline_record is not None
|
|
115
|
+
assert PLUGIN_ID in baseline_record["plugins"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_payload_without_a_session_id_should_write_nothing(
|
|
119
|
+
config_directory: Path, tmp_path: Path
|
|
120
|
+
) -> None:
|
|
121
|
+
write_installed_plugins(
|
|
122
|
+
config_directory,
|
|
123
|
+
{PLUGIN_ID: [_plugin_entry("1.0.0", "/cache/deepstack/1.0.0", "a")]},
|
|
124
|
+
)
|
|
125
|
+
run_writer("", "startup")
|
|
126
|
+
assert list((tmp_path / "state").iterdir()) == []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_a_missing_installed_plugins_file_should_record_an_empty_baseline(
|
|
130
|
+
config_directory: Path,
|
|
131
|
+
) -> None:
|
|
132
|
+
run_writer(SESSION_ID, "startup")
|
|
133
|
+
assert plugin_session_baseline.read_baseline_record(SESSION_ID) == {
|
|
134
|
+
"plugins": {},
|
|
135
|
+
"warned": [],
|
|
136
|
+
}
|
|
@@ -29,6 +29,8 @@ UNMEASURED_REASON_BY_SCRIPT = {
|
|
|
29
29
|
"routing/thread_spawn_pace_hook.py": "quotes a usage reading capped by its own constant",
|
|
30
30
|
"session/issue_tracker_session_starter.py": "emits only for a repository the user's "
|
|
31
31
|
"project registry names by absolute path",
|
|
32
|
+
"session/plugin_change_warning.py": "emits only after a plugin changes on disk mid-session, "
|
|
33
|
+
"once per plugin, naming plugin ids",
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from open_task_asks import is_ask, record_prompt, recorded_prompts
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
10
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
11
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
12
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize(
|
|
16
|
+
("prompt_text", "is_expected_ask"),
|
|
17
|
+
[
|
|
18
|
+
("Export the OneUI8 and OneUI9 APKs and submit them", True),
|
|
19
|
+
("can you check the release dates in neon?", True),
|
|
20
|
+
(
|
|
21
|
+
"set the 8 to ready to export in neon\n\ni dont think you're correct in none are released?",
|
|
22
|
+
True,
|
|
23
|
+
),
|
|
24
|
+
("wait, did /deepstack-mode fail?", False),
|
|
25
|
+
("are you monitoring these? yes or no", False),
|
|
26
|
+
("<task-notification>\n<task-id>b1</task-id>", False),
|
|
27
|
+
(" ", False),
|
|
28
|
+
("thanks!", False),
|
|
29
|
+
("ok, thanks", False),
|
|
30
|
+
("Looks good", False),
|
|
31
|
+
("ok, ship it", True),
|
|
32
|
+
("yes", True),
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
def test_should_classify_work_prompts_as_asks(prompt_text: str, is_expected_ask: bool) -> None:
|
|
36
|
+
assert is_ask(prompt_text) is is_expected_ask
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_should_number_asks_and_keep_every_prompt_in_order() -> None:
|
|
40
|
+
assert record_prompt("s-1", "Export the APKs") == "ask-1"
|
|
41
|
+
assert record_prompt("s-1", "did it fail?") is None
|
|
42
|
+
assert record_prompt("s-1", "Submit them") == "ask-2"
|
|
43
|
+
assert [each_prompt.ask_id for each_prompt in recorded_prompts("s-1")] == [
|
|
44
|
+
"ask-1",
|
|
45
|
+
None,
|
|
46
|
+
"ask-2",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_should_skip_a_session_id_that_is_not_a_plain_name() -> None:
|
|
51
|
+
assert record_prompt("../escape", "Export the APKs") is None
|
|
52
|
+
assert recorded_prompts("../escape") == []
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Behavior tests for the plugin session baseline record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import plugin_session_baseline
|
|
11
|
+
|
|
12
|
+
PLUGIN_ID = "pstack@pstack-claude"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
17
|
+
monkeypatch.setattr(
|
|
18
|
+
plugin_session_baseline.tempfile, "gettempdir", lambda: str(tmp_path)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_installed_plugins_path_should_follow_the_config_directory(
|
|
23
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
24
|
+
) -> None:
|
|
25
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
|
|
26
|
+
assert plugin_session_baseline.installed_plugins_path() == (
|
|
27
|
+
tmp_path / "plugins" / "installed_plugins.json"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_baseline_record_path_should_name_the_session_in_the_temp_folder(
|
|
32
|
+
tmp_path: Path,
|
|
33
|
+
) -> None:
|
|
34
|
+
assert plugin_session_baseline.baseline_record_path("a/b c") == (
|
|
35
|
+
tmp_path / "plugin-session-baseline-abc.json"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_current_plugin_snapshot_should_keep_only_version_and_install_path(
|
|
40
|
+
tmp_path: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
record_path = tmp_path / "installed_plugins.json"
|
|
43
|
+
record_path.write_text(
|
|
44
|
+
json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"plugins": {
|
|
47
|
+
PLUGIN_ID: [
|
|
48
|
+
{
|
|
49
|
+
"scope": "user",
|
|
50
|
+
"installPath": "/cache/pstack/1.0.0",
|
|
51
|
+
"version": "1.0.0",
|
|
52
|
+
"lastUpdated": "2026-10-21T21:26:12.000Z",
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
),
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
assert plugin_session_baseline.current_plugin_snapshot(record_path) == {
|
|
61
|
+
PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/pstack/1.0.0"}]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_current_plugin_snapshot_should_read_a_malformed_record_as_empty(
|
|
66
|
+
tmp_path: Path,
|
|
67
|
+
) -> None:
|
|
68
|
+
record_path = tmp_path / "installed_plugins.json"
|
|
69
|
+
record_path.write_text("{not json", encoding="utf-8")
|
|
70
|
+
assert plugin_session_baseline.current_plugin_snapshot(record_path) == {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_read_baseline_record_should_return_what_the_writer_stored() -> None:
|
|
74
|
+
snapshot = {PLUGIN_ID: [{"version": "1.0.0", "installPath": "/cache/pstack/1.0.0"}]}
|
|
75
|
+
plugin_session_baseline.write_baseline_record("session", snapshot, [PLUGIN_ID])
|
|
76
|
+
assert plugin_session_baseline.read_baseline_record("session") == {
|
|
77
|
+
"plugins": snapshot,
|
|
78
|
+
"warned": [PLUGIN_ID],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_all_changed_plugin_ids_should_name_changed_and_new_plugins_only() -> None:
|
|
83
|
+
unchanged_entries = [{"version": "1.0.0", "installPath": "/cache/a/1.0.0"}]
|
|
84
|
+
baseline_snapshot = {
|
|
85
|
+
"a@m": unchanged_entries,
|
|
86
|
+
"b@m": [{"version": "1.0.0", "installPath": "/cache/b/1.0.0"}],
|
|
87
|
+
}
|
|
88
|
+
current_snapshot = {
|
|
89
|
+
"a@m": unchanged_entries,
|
|
90
|
+
"b@m": [{"version": "2.0.0", "installPath": "/cache/b/2.0.0"}],
|
|
91
|
+
"c@m": [{"version": "1.0.0", "installPath": "/cache/c/1.0.0"}],
|
|
92
|
+
}
|
|
93
|
+
assert plugin_session_baseline.all_changed_plugin_ids(
|
|
94
|
+
baseline_snapshot, current_snapshot
|
|
95
|
+
) == [
|
|
96
|
+
"b@m",
|
|
97
|
+
"c@m",
|
|
98
|
+
]
|
|
@@ -11,6 +11,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
|
|
|
11
11
|
from transcript_skill_scan import (
|
|
12
12
|
invokes_skill_with_argument,
|
|
13
13
|
is_skill_loaded_after_last_compaction,
|
|
14
|
+
is_skill_loaded_since_last_prompt,
|
|
14
15
|
skill_invocation_status,
|
|
15
16
|
)
|
|
16
17
|
|
|
@@ -134,3 +135,26 @@ def test_build_eval_invocation_before_a_compact_boundary_should_still_count() ->
|
|
|
134
135
|
_skill_entry("other"),
|
|
135
136
|
]
|
|
136
137
|
assert _invokes_build_eval(lines)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _prompt_entry(content: object) -> str:
|
|
141
|
+
return json.dumps({"type": "user", "message": {"content": content}})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_skill_since_last_prompt_survives_tool_results_and_skill_bodies() -> None:
|
|
145
|
+
tool_result = _prompt_entry([{"type": "tool_result", "content": "Launching skill: verify"}])
|
|
146
|
+
skill_body = json.dumps({"type": "user", "isMeta": True, "message": {"content": "Base directory for this skill"}})
|
|
147
|
+
lines = [_prompt_entry("Commit it."), _skill_entry("verify"), tool_result, skill_body]
|
|
148
|
+
assert is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_next_prompt_drops_a_skill_loaded_in_the_last_turn() -> None:
|
|
152
|
+
lines = [_prompt_entry("First."), _skill_entry("verify"), _prompt_entry([{"type": "text", "text": "Second."}])]
|
|
153
|
+
assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_slash_command_prompt_loads_the_skill_for_its_own_turn() -> None:
|
|
157
|
+
marker = "<command-name>/verify</command-name>"
|
|
158
|
+
assert is_skill_loaded_since_last_prompt([_prompt_entry("First."), _prompt_entry(marker)], ("verify",), (marker,))
|
|
159
|
+
compaction = json.dumps({"subtype": "compact_boundary"})
|
|
160
|
+
assert not is_skill_loaded_since_last_prompt([_prompt_entry(marker), compaction], ("verify",), (marker,))
|
|
@@ -10,7 +10,9 @@ from hooks_constants.transcript_skill_scan_constants import (
|
|
|
10
10
|
ASSISTANT_ENTRY_TYPE,
|
|
11
11
|
COMMAND_ARGUMENTS_PATTERN,
|
|
12
12
|
COMPACT_BOUNDARY_SUBTYPE,
|
|
13
|
+
META_ENTRY_FIELD,
|
|
13
14
|
SKILL_TOOL_NAME,
|
|
15
|
+
TOOL_RESULT_BLOCK_TYPE,
|
|
14
16
|
TOOL_USE_BLOCK_TYPE,
|
|
15
17
|
USER_ENTRY_TYPE,
|
|
16
18
|
)
|
|
@@ -87,9 +89,7 @@ def _invokes_skill_with_argument(
|
|
|
87
89
|
)
|
|
88
90
|
|
|
89
91
|
|
|
90
|
-
def
|
|
91
|
-
if not any(name in each_line for name in skill_names) and COMPACT_BOUNDARY_SUBTYPE not in each_line:
|
|
92
|
-
return None
|
|
92
|
+
def _parsed_entry(each_line: str) -> dict[str, object] | None:
|
|
93
93
|
try:
|
|
94
94
|
all_entry_fields = json.loads(each_line)
|
|
95
95
|
except json.JSONDecodeError:
|
|
@@ -97,6 +97,12 @@ def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, o
|
|
|
97
97
|
return all_entry_fields if isinstance(all_entry_fields, dict) else None
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, object] | None:
|
|
101
|
+
if not any(name in each_line for name in skill_names) and COMPACT_BOUNDARY_SUBTYPE not in each_line:
|
|
102
|
+
return None
|
|
103
|
+
return _parsed_entry(each_line)
|
|
104
|
+
|
|
105
|
+
|
|
100
106
|
def skill_invocation_status(
|
|
101
107
|
all_transcript_lines: Iterable[str],
|
|
102
108
|
skill_names: Collection[str],
|
|
@@ -140,6 +146,51 @@ def is_skill_loaded_after_last_compaction(
|
|
|
140
146
|
return skill_invocation_status(all_transcript_lines, skill_names, slash_command_markers)[1]
|
|
141
147
|
|
|
142
148
|
|
|
149
|
+
def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
|
|
150
|
+
message = all_entry_fields.get("message")
|
|
151
|
+
if all_entry_fields.get("type") != USER_ENTRY_TYPE or all_entry_fields.get(META_ENTRY_FIELD):
|
|
152
|
+
return False
|
|
153
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
154
|
+
if isinstance(content, str):
|
|
155
|
+
return True
|
|
156
|
+
return isinstance(content, list) and not any(
|
|
157
|
+
isinstance(each_block, dict) and each_block.get("type") == TOOL_RESULT_BLOCK_TYPE
|
|
158
|
+
for each_block in content
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_skill_loaded_since_last_prompt(
|
|
163
|
+
all_transcript_lines: Iterable[str],
|
|
164
|
+
skill_names: Collection[str],
|
|
165
|
+
slash_command_markers: Collection[str],
|
|
166
|
+
) -> bool:
|
|
167
|
+
"""Return whether a matching invocation follows the last user prompt and compact boundary.
|
|
168
|
+
|
|
169
|
+
::
|
|
170
|
+
|
|
171
|
+
prompt, Skill verify, tool result, skill body -> True
|
|
172
|
+
prompt, Skill verify, prompt -> False
|
|
173
|
+
prompt carrying the /verify command marker -> True
|
|
174
|
+
prompt with the marker, compact boundary -> False
|
|
175
|
+
|
|
176
|
+
A tool result and a meta entry, such as the skill body, leave the turn open.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
180
|
+
skill_names: Accepted skill names, with plugin prefixes accepted.
|
|
181
|
+
slash_command_markers: Accepted user command tags.
|
|
182
|
+
"""
|
|
183
|
+
loaded = False
|
|
184
|
+
for each_line in all_transcript_lines:
|
|
185
|
+
all_entry_fields = _parsed_entry(each_line)
|
|
186
|
+
if all_entry_fields is None:
|
|
187
|
+
continue
|
|
188
|
+
if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE or _is_user_prompt(all_entry_fields):
|
|
189
|
+
loaded = False
|
|
190
|
+
loaded = loaded or _invokes_skill(all_entry_fields, skill_names, slash_command_markers)
|
|
191
|
+
return loaded
|
|
192
|
+
|
|
193
|
+
|
|
143
194
|
def invokes_skill_with_argument(
|
|
144
195
|
all_transcript_lines: Iterable[str],
|
|
145
196
|
skill_names: Collection[str],
|
package/package.json
CHANGED
|
@@ -128,6 +128,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
128
128
|
|
|
129
129
|
hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
|
|
130
130
|
hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
|
|
131
|
+
hooks/blocking/verify_before_commit_gate.py -> exempt
|
|
131
132
|
hooks/blocking/step_note_gate.py -> exempt
|
|
132
133
|
hooks/blocking/reply_length_gate.py -> exempt
|
|
133
134
|
hooks/blocking/chat_reply_gate.py -> exempt
|
|
@@ -155,6 +156,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
155
156
|
a second open ``Follow-up to #N`` pull request for one parent, a delivery
|
|
156
157
|
rule that names the open follow-up to extend.
|
|
157
158
|
|
|
159
|
+
The verify-before-commit gate denies a code commit in a repository with a
|
|
160
|
+
verify skill until the session loads that skill in the current turn. It
|
|
161
|
+
decides when a skill loads and no code or safety policy.
|
|
162
|
+
|
|
158
163
|
The step-note gate allows every call until the user runs ``/step-notes on``.
|
|
159
164
|
It asks for a readable status line and decides no code or safety policy.
|
|
160
165
|
|
|
@@ -64,6 +64,7 @@ ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
|
|
|
64
64
|
ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
65
65
|
"blocking/bash_pre_tool_use_dispatcher.py",
|
|
66
66
|
"blocking/pr_lifecycle_skill_gate.py",
|
|
67
|
+
"blocking/verify_before_commit_gate.py",
|
|
67
68
|
"blocking/merge_review_gate.py",
|
|
68
69
|
"blocking/step_note_gate.py",
|
|
69
70
|
"blocking/reply_length_gate.py",
|
|
@@ -170,6 +170,14 @@ def test_new_eval_write_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
170
170
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
171
171
|
|
|
172
172
|
|
|
173
|
+
def test_new_verify_before_commit_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
174
|
+
current_document = _hook_document(
|
|
175
|
+
["hooks/blocking/verify_before_commit_gate.py"],
|
|
176
|
+
'{"hooks": {}}',
|
|
177
|
+
)
|
|
178
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
179
|
+
|
|
180
|
+
|
|
173
181
|
def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
|
|
174
182
|
current_document = _hook_document(
|
|
175
183
|
["hooks/blocking/new.py"],
|