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.
Files changed (46) hide show
  1. package/bin/install-pstack-plugin.mjs +31 -4
  2. package/bin/install-pstack-plugin.test.mjs +23 -0
  3. package/bin/install.prompt-context.test.mjs +3 -1
  4. package/bin/install.settings-defaults.test.mjs +4 -3
  5. package/docs/agents-guidance/asks.md +6 -1
  6. package/hooks/blocking/open_tasks_stop_gate.py +128 -34
  7. package/hooks/blocking/open_tasks_transcript.py +112 -5
  8. package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
  9. package/hooks/blocking/python_windows_invocation_gate.py +249 -0
  10. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  11. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  12. package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
  13. package/hooks/blocking/test_open_tasks_transcript.py +89 -0
  14. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  15. package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
  16. package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
  17. package/hooks/blocking/verify_before_commit_gate.py +278 -0
  18. package/hooks/features/bash-dispatchers.md +3 -0
  19. package/hooks/features/conduct-gates.md +10 -2
  20. package/hooks/features/session-context.md +4 -0
  21. package/hooks/hooks.json +25 -0
  22. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  23. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
  24. package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
  25. package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
  26. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  27. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  28. package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
  29. package/hooks/open_task_asks.py +163 -0
  30. package/hooks/plugin_session_baseline.py +190 -0
  31. package/hooks/session/open_task_ask_recorder.py +38 -0
  32. package/hooks/session/plugin_change_warning.py +90 -0
  33. package/hooks/session/plugin_session_baseline_writer.py +53 -0
  34. package/hooks/session/test_open_task_ask_recorder.py +57 -0
  35. package/hooks/session/test_plugin_change_warning.py +216 -0
  36. package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
  37. package/hooks/test_context_budget_hooks.py +2 -0
  38. package/hooks/test_open_task_asks.py +52 -0
  39. package/hooks/test_plugin_session_baseline.py +98 -0
  40. package/hooks/test_transcript_skill_scan.py +24 -0
  41. package/hooks/transcript_skill_scan.py +54 -3
  42. package/package.json +1 -1
  43. package/scripts/policy_lint/adapter_configuration.py +5 -0
  44. package/scripts/policy_lint/config/constants.py +1 -0
  45. package/scripts/tests/test_adapter_configuration.py +8 -0
  46. 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 _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, object] | None:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.96.8",
3
+ "version": "8.98.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"],